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
73 changes: 73 additions & 0 deletions src/codegen/scan/pack_refs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -897,3 +897,76 @@ test "bundleHeaderLegacyEntitiesOffset: dead entities on a header are flagged, n
try std.testing.expect(std.mem.indexOf(u8, out, "\"components\": { \"sky__SkyBody\": { \"role\": \"sun\" }") != null);
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "sky__SkyBody"));
}

test "rewritePackLocalRefs: component keys inside a wrapped @ target patch are rewritten (labelle-engine#801)" {
const allocator = std.testing.allocator;
const src =
\\{ "prefab": "base", "overrides": { "@slot": { "Worker": { "hp": 9 } } } }
;
const out = try rewritePackLocalRefs(allocator, src, &.{"Worker"}, &.{}, "citizens");
defer allocator.free(out);

// The `@` key itself is structure — never namespaced …
try std.testing.expect(std.mem.indexOf(u8, out, "\"@slot\":") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "citizens__@slot") == null);
// … but the pack component key INSIDE the target patch is.
try std.testing.expect(std.mem.indexOf(u8, out, "\"citizens__Worker\": { \"hp\": 9 }") != null);
}

test "rewritePackLocalRefs: component keys inside a flat @ target patch are rewritten (labelle-engine#801)" {
const allocator = std.testing.allocator;
const src =
\\{ "prefab": "base", "@slot": { "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") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "citizens__@slot") == null);
}

test "wrapFlatEntityComponents: a flat @ pair moves into the synthesized wrapper (labelle-engine#801)" {
const allocator = std.testing.allocator;
// The flat pack-local `Worker` triggers the wrap; the `@slot` pair must
// ride into the SAME synthesized `overrides` wrapper — leaving it
// outside would manufacture a hybrid entry the author never wrote, and
// the engine would then warn wrapper-wins and DROP the target.
const src =
\\{ "prefab": "base", "Worker": { "hp": 1 }, "@slot": { "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, "\"@slot\":").?;
// Exactly one wrapper, with the @ pair inside it.
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\"overrides\": {"));
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);
}
27 changes: 26 additions & 1 deletion src/codegen/scan/pack_refs/common.zig
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,31 @@ pub fn isPascalCase(name: []const u8) bool {
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");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +146 to +156

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode escaped target keys before rewriting packs

When a pack uses the valid JSON spelling "\u0040slot", the engine decodes it to @slot, and the new version-gate scanner explicitly recognizes that spelling. However, the pack-rewrite scanners pass the raw, unescaped key to this function, so it returns false: pass 2 treats the target's value as opaque payload and does not namespace pack-local component keys, while pass 1 can leave the target outside a synthesized overrides wrapper. Thus a supported-engine build can silently lose the target patch or its pack components; recognize/decode the escaped spelling consistently in the rewrite walkers.

Useful? React with 👍 / 👎.


/// 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);
}

/// 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
/// `childArrayScope` and pass 1's flat-wrap recursion so the two walks can
Expand Down Expand Up @@ -181,7 +206,7 @@ pub fn isOnlyMetaHeaderObject(src: []const u8, open: usize) bool {
std.mem.eql(u8, key, "components") or
std.mem.eql(u8, key, "overrides") or
std.mem.eql(u8, key, "ref") or
isPascalCase(key))
isFlatComponentKey(key))
{
// Any entity-shape key disqualifies — this is an entity that
// happens to carry a `meta` field, not a file header.
Expand Down
15 changes: 10 additions & 5 deletions src/codegen/scan/pack_refs/pass1.zig
Original file line number Diff line number Diff line change
Expand Up @@ -225,17 +225,22 @@ const FlatWrap = struct {
var emitted_any = false;
var wrapper_done = false;
for (obj.pairs) |p| {
if (wrap and common.isPascalCase(p.key)) {
// Every Pascal pair moves into the single wrapper, emitted
// at the FIRST Pascal pair's position (with its leading
// trivia); later Pascal pairs were already emitted inside.
if (wrap and common.isFlatComponentKey(p.key)) {
// Every flat patch-content pair (PascalCase component or
// `@` target, labelle-engine#801) moves into the single
// wrapper, emitted at the FIRST such pair's position (with
// its leading trivia); later ones were already emitted
// inside. Leaving an `@` pair OUTSIDE the synthesized
// wrapper would manufacture a hybrid entry the author never
// wrote — and the engine would then warn wrapper-wins and
// drop the target.
if (wrapper_done) continue;
if (emitted_any) try out.append(allocator, ',');
try out.appendSlice(allocator, s[p.lead_start..p.key_start]);
try out.appendSlice(allocator, if (is_reference) "\"overrides\": {" else "\"components\": {");
var first = true;
for (obj.pairs) |q| {
if (!common.isPascalCase(q.key)) continue;
if (!common.isFlatComponentKey(q.key)) continue;
if (!first) try out.append(allocator, ',');
if (first) {
// Its original lead was spent on the wrapper key.
Expand Down
18 changes: 16 additions & 2 deletions src/codegen/scan/pack_refs/pass2.zig
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,11 @@ fn childScope(parent: ?Scope, pending_key: ?[]const u8) Scope {
if (std.mem.eql(u8, k, "components") or std.mem.eql(u8, k, "overrides")) {
break :blk .component_map;
}
// A flat `@` target's value is a component map
// (labelle-engine#801) — its keys must be rewritten like
// any other component reference, or a pack-local name
// under `@` silently misses the namespace rewrite.
if (common.isTargetKey(k)) break :blk .component_map;
}
// Any other object field on an entity (`meta`, etc.) is opaque.
break :blk .payload;
Expand All @@ -264,8 +269,17 @@ fn childScope(parent: ?Scope, pending_key: ?[]const u8) Scope {
// duplicate later `"root"` member lands here and stays dead-data
// verbatim (CodeRabbit on #521).
.file_container => .payload,
// A component's value, or anything already inside payload, stays opaque.
.component_map, .payload, .array_other => .payload,
// Inside a component map, a `@` target's value is ANOTHER
// component map (labelle-engine#801); a component's value is
// opaque payload.
.component_map => blk: {
if (pending_key) |k| {
if (common.isTargetKey(k)) break :blk .component_map;
}
break :blk .payload;
},
// Anything already inside payload stays opaque.
.payload, .array_other => .payload,
};
}

Expand Down
45 changes: 45 additions & 0 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,51 @@ pub fn generate(
const scene_manifests = try scene_manifest.parseSceneDir(allocator, scenes_target, jsonc_scene_names);
defer scene_manifest.freeManifests(allocator, scene_manifests);

// ── `@` target-override version gate (labelle-engine#801) ──────────
// Compat checking is MAJOR-only (labelle-cli#269), so "new assembler +
// old engine pin" is a legal combo — and an engine older than the
// feature silently DROPS `@` keys, which is the exact silent no-op
// #801 exists to kill, reintroduced through the side door. Hard-error
// 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`.
if (!scene_manifest.engineSupportsTargetOverrides(cfg.engine_version)) {
const prefabs_target = try std.fs.path.join(allocator, &.{ target_dir, "prefabs" });
defer allocator.free(prefabs_target);
var hit: ?[]const u8 =
try scene_manifest.findTargetKeyUsage(allocator, scenes_target, jsonc_scene_names) orelse
try scene_manifest.findTargetKeyUsage(allocator, prefabs_target, prefab_names);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Pack sources too: pack prefabs are not staged until
// `loadPackScans` later in generate, so scan each pack's SOURCE
// prefab tree here — a light pack shipping an `@` override
// would otherwise slip past the gate (codex P1 / CodeRabbit on
// #650). Prefabs ONLY: `scanPack` stages components/events/
// prefabs/hooks and never a pack's `scenes/`, so an example or
// fixture scene must not impose an engine-version requirement
// on the game (codex P2, round 2).
if (hit == null) {
for (pack_entries.items) |entry| {
const src_dir = try entry.resolveSrcDir(allocator, game_dir);
defer allocator.free(src_dir);
const sub_dir = try std.fs.path.join(allocator, &.{ src_dir, "prefabs" });
defer allocator.free(sub_dir);
hit = try scene_manifest.findTargetKeyUsageInTree(allocator, sub_dir);
if (hit != null) break;
}
}
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",
.{ offender, cfg.engine_version, scene_manifest.MIN_ENGINE_FOR_TARGET_OVERRIDES },
);
return error.EngineTooOldForTargetOverrides;
}
}

// ── Asset-Plugins Phase 1: scene auto-wiring (#575) ────────────────
// A scene that instantiates any prefab from a pack gets that pack's
// non-lazy resources auto-added to its asset manifest, so a scene using
Expand Down
Loading
Loading