Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 0 additions & 26 deletions src/codegen/scan/pack_refs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -944,29 +944,3 @@ test "wrapFlatEntityComponents: a flat @ pair moves into the synthesized wrapper
try std.testing.expect(target_at > wrapper_at);
try std.testing.expect(std.mem.indexOf(u8, out, "citizens__Worker") != null);
}

test "rewritePackLocalRefs: the JSON-escaped @ spelling opens a component map too (labelle-engine#801)" {
const allocator = std.testing.allocator;
// `"\u0040slot"` decodes to `@slot` at engine load — the raw-byte
// walkers must treat it as a target key or the pack rewrite is
// silently lost on a supported engine (codex round 2 on #650).
const src =
\\{ "prefab": "base", "overrides": { "\u0040slot": { "Worker": { "hp": 9 } } } }
;
const out = try rewritePackLocalRefs(allocator, src, &.{"Worker"}, &.{}, "citizens");
defer allocator.free(out);
try std.testing.expect(std.mem.indexOf(u8, out, "\"citizens__Worker\": { \"hp\": 9 }") != null);
}

test "wrapFlatEntityComponents: a flat escaped-@ pair rides into the wrapper (labelle-engine#801)" {
const allocator = std.testing.allocator;
const src =
\\{ "prefab": "base", "Worker": { "hp": 1 }, "\u0040slot": { "Position": { "x": 1 } } }
;
const out = try rewritePackLocalRefs(allocator, src, &.{"Worker"}, &.{}, "citizens");
defer allocator.free(out);
const wrapper_at = std.mem.indexOf(u8, out, "\"overrides\": {").?;
const target_at = std.mem.indexOf(u8, out, "\"\\u0040slot\":").?;
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\"overrides\": {"));
try std.testing.expect(target_at > wrapper_at);
}
58 changes: 18 additions & 40 deletions src/codegen/scan/pack_refs/common.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//! split of `scan/pack_refs.zig` (labelle-assembler#534 follow-up).
//!
//! Owns the low-level JSONC byte scanners (`skipTrivia`/`scanString`/
//! `scanValue`/`scanBalanced`), the pure key classifiers (`isPascalCase`/
//! `scanValue`/`scanBalanced`), the key classifiers (`isTargetKey`/
//! `isFlatComponentKey`, re-exported from `scene_keys.zig`, plus
//! `isEntityListKey`/`containsKey`), and the container-shape probes that
//! mirror the engine's `unified_format.zig` (`isOnlyMetaHeaderObject`/
//! `bundleHeaderOpen`/`rootWrapperValueOpen`/`bundleHeaderLegacyEntitiesOffset`).
Expand All @@ -13,6 +14,7 @@
//! the free functions here directly.

const std = @import("std");
const scene_keys = @import("../../../scene_keys.zig");

pub const Error = error{ Malformed, OutOfMemory };

Expand Down Expand Up @@ -124,42 +126,18 @@ pub fn scanBalanced(src: []const u8, at: usize) Error!usize {
return error.Malformed;
}

/// Byte-parity twin of the engine's `unified_format.zig` `isPascalCase`
/// (RFC #596): an entity-scope key is a flat component declaration iff its
/// first byte is an ASCII uppercase letter; everything else (lowercase
/// structural keys like `prefab`/`children`/`meta`/`ref`, empty, non-ASCII
/// start) is structural. The flat-wrap transform must classify keys EXACTLY
/// like the engine's flat loader, or the copy's shape drifts from what the
/// author's file would have meant at game root.
pub fn isPascalCase(name: []const u8) bool {
if (name.len == 0) return false;
return name[0] >= 'A' and name[0] <= 'Z';
}

/// Byte-parity twin of the engine's `unified_format.zig` `isTargetKey`
/// (labelle-engine#801): `"@<ref>"` on a prefab reference's override map
/// targets a ref-named entity inside the referenced prefab's body. Like
/// PascalCase component keys, `@` keys are flat patch CONTENT at entity
/// scope — pass 1 must move them into the synthesized wrapper and pass 2
/// must open a component map for their values, or the copy's shape drifts
/// from the author's meaning.
pub fn isTargetKey(name: []const u8) bool {
if (name.len > 1 and name[0] == '@') return true;
// The JSON-escaped spelling decodes to `@` at engine load. These
// walkers classify RAW bytes, so recognize it here too — otherwise
// pass 1 leaves an escaped target outside the synthesized wrapper
// and pass 2 treats its value as opaque payload, silently losing
// the pack-namespace rewrite on a supported engine (codex P2 on
// #650). The PascalCase analog (`\u0057orker`) is the broader
// pre-existing class tracked in #651.
return name.len > 6 and std.mem.startsWith(u8, name, "\\u0040");
}

/// A flat patch-content key at entity scope: PascalCase component
/// (RFC #596 axis 2) or `@` target (labelle-engine#801).
pub fn isFlatComponentKey(name: []const u8) bool {
return isPascalCase(name) or isTargetKey(name);
}
/// Key classification is shared with every other scene walker through
/// `scene_keys.zig` (labelle-assembler#651, #652): byte-parity with the
/// engine's `unified_format.zig`, applied to RAW key spans (see that
/// module for why raw bytes classify exactly like the engine's decoded
/// keys, and why an escape the engine rejects makes a key inert). A flat
/// patch-content key at entity scope is a component-shaped key
/// (PascalCase or `<prefix>__<Pascal>`, engine#806) or a `@` target
/// (engine#801). Pass 1 must move all of them into the synthesized
/// wrapper, and pass 2 must open a component map for a target's value, or
/// the copy's shape drifts from the author's meaning.
pub const isTargetKey = scene_keys.rawIsTargetKey;
pub const isFlatComponentKey = scene_keys.rawIsFlatComponentKey;

/// The two array-valued entity-list keys the engine walks (`children` on any
/// entity scope, legacy `entities` at file level). Shared between pass 2's
Expand All @@ -179,9 +157,9 @@ pub fn containsKey(keys: []const []const u8, needle: []const u8) bool {
/// Byte-parity twin of the engine's `unified_format.zig` `isFileHeader`
/// (RFC #596, #516): true iff the object opening at `open` is a file-level
/// metadata header — it carries a `"meta"` key and NO entity-shape key
/// (`prefab`, `children`, `components`, `overrides`, `ref`, or any
/// PascalCase key). Other lowercase keys are tolerated on a header, exactly
/// as the engine tolerates them. `{}` carries no `meta` and is NOT a header
/// (`prefab`, `children`, `components`, `overrides`, `ref`, or any flat
/// content key: component-shaped or `@` target, `isFlatComponentKey`).
/// Other lowercase keys are tolerated on a header, exactly as the engine tolerates them. `{}` carries no `meta` and is NOT a header
/// (the engine treats it as an entity and rejects it at load), and any
/// object this scanner cannot fully account for is not a header either —
/// both rewrite passes then keep treating the element as an entity, so the
Expand Down
3 changes: 3 additions & 0 deletions src/codegen/scan/pack_refs/pass1.zig
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const common = @import("common.zig");
/// learns to accept `<pack>__Pascal` keys (they are the documented
/// global-registry key format — a prerequisite for engine v2.0, which drops
/// the wrapper entirely), this pass can flip to an in-place key rewrite.
/// engine#806 now accepts flat `<pack>__Pascal` keys, so pass 1 also moves
/// any flat namespaced pair into the wrapper (labelle-assembler#652); the
/// wrap itself stays because it loads on engines older than #806 too.
///
/// **What is transformed.** For each entity object (the file's entity
/// content in any of the three accepted top-level shapes — see `emitRoot` —
Expand Down
39 changes: 35 additions & 4 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ test {
_ = @import("scene_name_lint.zig");
_ = @import("scene_manifest.zig");
_ = @import("scene_manifest_test.zig");
_ = @import("scene_keys_test.zig"); // #651/#652 key classification
_ = @import("scene_override_check.zig");
_ = @import("tilemap_scan_test.zig"); // covers tilemap_scan + tilemap_scene_scan
_ = @import("asset_validator.zig");
Expand Down Expand Up @@ -854,7 +855,10 @@ pub fn generate(
// at generate instead. Covers scenes AND prefabs (a prefab body can
// use `@` on its own ref-array entries). Unparseable pins (`local:`
// dev overrides, branch pins) pass — see
// `engineSupportsTargetOverrides`.
// `engineSupportsTargetOverrides`. Flat pack-namespaced component keys
// (engine#806, labelle-assembler#652) shipped in the same engine
// release and are dropped the same way, so the gate covers them too
// (`sourceNeedsV211Keys`).
if (!scene_manifest.engineSupportsTargetOverrides(cfg.engine_version)) {
const prefabs_target = try std.fs.path.join(allocator, &.{ target_dir, "prefabs" });
defer allocator.free(prefabs_target);
Expand Down Expand Up @@ -882,9 +886,9 @@ pub fn generate(
if (hit) |offender| {
defer allocator.free(offender);
std.debug.print(
"labelle-assembler: '{s}' uses \"@<ref>\" target-override keys (labelle-engine#801), but the pinned engine v{s} predates them (needs >= v{s}).\n" ++
" An older engine silently DROPS `@` keys — the exact failure this syntax replaces.\n" ++
" Bump `engine_version` in project.labelle, or remove the `@` overrides.\n",
"labelle-assembler: '{s}' uses \"@<ref>\" target-override keys (labelle-engine#801) or flat \"<pack>__Pascal\" component keys (labelle-engine#806), but the pinned engine v{s} predates them (needs >= v{s}).\n" ++
" An older engine silently DROPS those keys — the exact failure this syntax replaces.\n" ++
" Bump `engine_version` in project.labelle, or remove the `@` overrides / move namespaced keys into a \"components\"/\"overrides\" wrapper.\n",
.{ offender, cfg.engine_version, scene_manifest.MIN_ENGINE_FOR_TARGET_OVERRIDES },
);
return error.EngineTooOldForTargetOverrides;
Expand Down Expand Up @@ -1248,6 +1252,33 @@ pub fn generate(
pack_scans.deinit(allocator);
}

// Flat namespaced keys in pack prefabs (engine#806) are gated on the
// STAGED, rewritten copies: pack rewrite pass 1 wraps any entity that
// declares a pack-local component, and a wrapped key loads on every
// engine. Only keys still flat after that would be dropped by an
// engine older than v2.11.0 (codex on #798).
if (!scene_manifest.engineSupportsTargetOverrides(cfg.engine_version)) {
// Prefabs only: a pack's other staged `.jsonc` (locales, …) is not
// entity data.
var flat_hit: ?[]const u8 = null;
for (pack_scans.items) |ps| {
const staged_prefabs = try std.fs.path.join(allocator, &.{ target_dir, "packs", ps.name, "prefabs" });
defer allocator.free(staged_prefabs);
flat_hit = try scene_manifest.findFlatNamespacedKeyUsageInTree(allocator, staged_prefabs);
if (flat_hit != null) break;
}
if (flat_hit) |offender| {
defer allocator.free(offender);
std.debug.print(
"labelle-assembler: '{s}' uses flat \"<pack>__Pascal\" component keys (labelle-engine#806), but the pinned engine v{s} predates them (needs >= v{s}).\n" ++
" An older engine silently DROPS those keys.\n" ++
" Bump `engine_version` in project.labelle, or move the namespaced keys into a \"components\"/\"overrides\" wrapper.\n",
.{ offender, cfg.engine_version, scene_manifest.MIN_ENGINE_FOR_TARGET_OVERRIDES },
);
return error.EngineTooOldForTargetOverrides;
}
}

// ── Asset-Plugins Phase 1: copy + namespace + validate pack assets ──
// Runs AFTER `loadPackScans` copied each pack's convention dirs (its
// prefabs are now under `<target>/packs/<pack>/prefabs/`). For every pack
Expand Down
197 changes: 197 additions & 0 deletions src/scene_keys.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
//! Scene/prefab JSONC key classification: the one assembler-side copy of
//! the engine's key rules (labelle-assembler#651, #652).
//!
//! Every assembler walker that decides what a scene/prefab key MEANS: the
//! pack-namespace rewrite (`codegen/scan/pack_refs/{common,pass1,pass2}`),
//! the bare-name lint and `@` version gate (`scene_name_lint`), and the
//! scene manifest validator (`scene_manifest`). All of them classify through
//! this file, so they cannot drift from one another or from the engine.
//!
//! Two engine sources are mirrored byte-for-byte:
//!
//! * **Key shapes:** labelle-engine `src/jsonc/unified_format.zig`
//! (`isPascalCase`, `isComponentKeyShape`, `isTargetKey`,
//! `isFlatComponentKey`). engine#806 widened flat-form content from
//! PascalCase-only to include pack-namespaced `<prefix>__<Pascal>` keys,
//! so a flat `rooms__Room` at entity scope is a real component (#652).
//! * **String escapes:** labelle-engine `jsonc/src/parser.zig`
//! `parseString`. The engine decodes exactly `\n \t \r \b \f \\ \" \/`
//! and fails the WHOLE file with `error.InvalidEscape` on anything else,
//! `\uXXXX` included. `\u` has never been supported (checked against
//! every revision of that parser).
//!
//! **Why raw bytes can be classified directly (#651).** The textual walkers
//! see raw key spans, while the engine classifies decoded keys. Those two
//! agree for every key the engine can load. An accepted escape starts with
//! `\` and decodes to one of `\n \t \r \b \f \ " /`. None of those bytes (nor
//! `\` itself) is `A`-`Z`, `@` or `_`, and none of them appears in a
//! lowercase structural key (`prefab`, `children`, `components`, …). So the
//! first byte, every `__` position, the byte after the last `__`, and every
//! exact structural-name match are the same before and after decoding.
//! The only keys where raw and decoded bytes differ in meaning are keys
//! with an escape the engine REJECTS (`"\u0057orker"`, `"\u0040slot"`).
//! Those never reach the engine's classifier because the file fails to
//! load. `classifyRaw` returns `.unloadable` for them, so walkers treat
//! them as inert, and `scene_manifest` rejects such a file at build time
//! (`firstInvalidEscape`) instead of letting `std.json`, which DOES decode
//! `\u`, give it a meaning the engine never will. `decode` is the reference
//! implementation the tests check this against.

const std = @import("std");

// ── Decoded-key rules (byte-parity with engine unified_format.zig) ────────

/// PascalCase (RFC #596): first byte is ASCII `A`-`Z`. Empty and
/// non-ASCII-start names are structural.
pub fn isPascalCase(name: []const u8) bool {
if (name.len == 0) return false;
return name[0] >= 'A' and name[0] <= 'Z';
}

/// A component-shaped key: PascalCase, or pack-namespaced
/// `<prefix>__<Pascal>` with a nonempty prefix and a PascalCase suffix
/// after the LAST `__` (engine #803/#806: `industry__TendableWorkstation`
/// yes, `capacity__oops` no, `__Worker` no).
pub fn isComponentKeyShape(name: []const u8) bool {
if (isPascalCase(name)) return true;
const i = std.mem.lastIndexOf(u8, name, "__") orelse return false;
if (i == 0) return false;
return isPascalCase(name[i + 2 ..]);
}

/// `@<ref>` target-override key (engine #801). A bare `@` is structural.
pub fn isTargetKey(name: []const u8) bool {
return name.len > 1 and name[0] == '@';
}

/// Flat patch content at entity scope: a component-shaped key or a `@`
/// target. Everything else at entity scope is structural.
pub fn isFlatComponentKey(name: []const u8) bool {
return isComponentKeyShape(name) or isTargetKey(name);
}

// ── Escape decoding (byte-parity with engine jsonc parser.zig) ────────────

/// The byte an accepted escape letter decodes to, or null when the engine's
/// JSONC parser rejects the escape (`error.InvalidEscape`).
pub fn escapeByte(letter: u8) ?u8 {
return switch (letter) {
'n' => '\n',
't' => '\t',
'r' => '\r',
'b' => 0x08,
'f' => 0x0C,
'\\' => '\\',
'"' => '"',
'/' => '/',
else => null,
};
}

/// Decode a raw string-literal body (the bytes between the quotes) the way
/// the engine does, into `buf`. `error.InvalidEscape` is exactly the
/// engine's load failure. A trailing lone `\` is also invalid there.
pub fn decode(raw: []const u8, buf: []u8) error{ InvalidEscape, NoSpaceLeft }![]const u8 {
var n: usize = 0;
var i: usize = 0;
while (i < raw.len) : (i += 1) {
var b = raw[i];
if (b == '\\') {
i += 1;
if (i >= raw.len) return error.InvalidEscape;
b = escapeByte(raw[i]) orelse return error.InvalidEscape;
}
if (n >= buf.len) return error.NoSpaceLeft;
buf[n] = b;
n += 1;
}
return buf[0..n];
}

/// Offset (within `raw`) of the first escape the engine rejects, or null.
pub fn invalidEscapeIn(raw: []const u8) ?usize {
var i: usize = 0;
while (i < raw.len) : (i += 1) {
if (raw[i] != '\\') continue;
if (i + 1 >= raw.len or escapeByte(raw[i + 1]) == null) return i;
i += 1;
}
return null;
}

// ── Raw-key classification for the textual walkers ────────────────────────

pub const Class = enum {
/// Lowercase structural key (`prefab`, `children`, `meta`, …) or any
/// other non-content key.
structural,
/// Component-shaped key (PascalCase or `<prefix>__<Pascal>`).
component,
/// `@<ref>` target-override key.
target,
/// Carries an escape the engine rejects, so the file never loads and
/// the key has no meaning. Walkers treat it as inert.
unloadable,
};

/// Classify a raw key span (string-literal body, escapes undecoded) as the
/// engine classifies the decoded key. See the module doc for why no decode
/// buffer is needed.
pub fn classifyRaw(raw: []const u8) Class {
if (invalidEscapeIn(raw) != null) return .unloadable;
if (isTargetKey(raw)) return .target;
if (isComponentKeyShape(raw)) return .component;
return .structural;
}

pub fn rawIsTargetKey(raw: []const u8) bool {
return classifyRaw(raw) == .target;
}

pub fn rawIsComponentKeyShape(raw: []const u8) bool {
return classifyRaw(raw) == .component;
}

pub fn rawIsFlatComponentKey(raw: []const u8) bool {
return switch (classifyRaw(raw)) {
.component, .target => true,
.structural, .unloadable => false,
};
}

// ── Whole-file escape check (engine load parity) ───────────────────────────

/// Byte offset of the first string escape in `src` that the engine's JSONC
/// parser rejects, or null if it has none. Comment-aware, so a `\u` inside a
/// `//` or `/* */` comment is ignored exactly as the engine ignores it.
/// Strings are scanned for the escape wherever they sit: the engine's
/// parser decodes keys and values alike, and one bad escape fails the file.
pub fn firstInvalidEscape(src: []const u8) ?usize {
var i: usize = 0;
while (i < src.len) {
const c = src[i];
if (c == '/' and i + 1 < src.len and src[i + 1] == '/') {
i = std.mem.indexOfScalarPos(u8, src, i, '\n') orelse src.len;
continue;
}
if (c == '/' and i + 1 < src.len and src[i + 1] == '*') {
const close = std.mem.indexOfPos(u8, src, i + 2, "*/");
i = if (close) |p| p + 2 else src.len;
continue;
}
if (c == '"') {
var j = i + 1;
while (j < src.len) : (j += 1) {
if (src[j] == '"') break;
if (src[j] == '\\') {
if (j + 1 >= src.len or escapeByte(src[j + 1]) == null) return j;
j += 1;
}
}
i = j + 1;
continue;
}
i += 1;
}
return null;
}
Loading
Loading