Free-Field Generation (RE-measured)
A PSZ free field is not a hand-drawn dungeon and it is not the
invented 5×5 sketch the old generator produced. It is a small, gated tree of
reused room tiles whose shape, gates, keys, and enemy tables were
measured out of the original ROM by the
psz-re project. This page is
the normative contract for reproducing one to parity. The reference
observations live in data/re_reference/; the Valley field
(data/field_quests/valley_field.json) is the first built to it.
The room vocabulary
Every stage ships the same room codes; the first letter of the code encodes
the room's door count and shape — this is confirmed by the
game's own name-format table (psz-re FUN_0206D2E4):
sstart ·ggoal ·ndead end — 1 door eachistraight (2 doors, opposite walls) ·lL-corner (2 doors, adjacent walls)tT-junction (3 doors) ·xcross (4 doors)
A field's variant letter is its area role: a/b
are the two explorable areas, e the single-room transition between
them, z the boss arena. So a full field is four sections —
grid a → transition e → grid b → boss z.
The layout is always a tree
The generator MUST satisfy these invariants (measured across four fields, 36 rooms; tested in test_field_layout.gd):
- Room 0 is always the start (shape
s); there is exactly one goal (shapeg), and it is not necessarily last. - The layout is a tree: edges = rooms − 1, no cycles, adjacency symmetric.
- The number of doors on a room equals the door count its shape letter implies.
- Each room stores an absolute grid cell (column A.., row 1..5); the in-game map draws a fixed grid the layout occupies part of.
The goal room ends the section
Every a/b section has exactly one goal room
— the set's ga1 — and it is the section's terminal: a
1-door leaf whose only door is the connection it is entered through.
The section exit lives inside the room, as an in-room warp pad
standing where the original authors its warp object — never as a doorway warp bolted
onto whatever room the path happened to finish in. A generator MUST reserve
ga1 for this role and never roll it as an ordinary mid-path room.
- Four independent confirmations, all in
data/re_reference/: the naming table (level_room_naming.json, arm90x020F24AD) orders the shape letter by connectivity and listsgamong the 1-door terminals (dead endn, starts, goalg); the doorway measurements (room_doorways.json, from the ROM'slndprtables) give 20 of 20ga1rooms exactly one doorway; the captured layouts (level_layouts_observed.json) hold exactly onegper section; and the observed graphs (level_graph_observed.json) always show it as a leaf with a single inbound edge. - The goal room is not necessarily the list-last room of a section — the
table order is generation order, not depth. The rule is "the section's terminal
is the
garoom", not "the last room is the goal". - The goal room MAY sit behind a gate: valley, wetlands and
snowfield measure it behind an enemy-defeat gate, paru behind a two-key gate
(
level_graph_observed.json). The gate lives on the parent room's forward door; the goal room itself is never a gated room. room_reference.jsonshows every goal room carries an authored in-room warp object (o0s_warpminavariants,o0s_warpbinb), and that object's position is where a port's section exit stands. psz-godot's portal tables originally misread that position as a second north doorway and authored a phantom portal from it (corrected in #641, measured rather than designed). The tower floors (s08x_ga1) carry the same phantom and are tracked separately.
Rotation is derived, not stored
The game does not store a room's rotation. A port MUST derive it: rotate the
room's door set until it covers the directions the layout needs at that
cell — there is exactly one 90° multiple that fits (psz-re
"Rooms are rotated, and the rotation is derived"). In this engine the
room's authored config portal directions are the door set, and
StageRotation.rotate_dir is a logical label remap (never a
mesh rotation — see Field Stage Lifecycle and issue #218).
Gates and keys
/states/field-gates is normative for the gate rule — the four attribute values, the key-gate budget, how one key versus two is chosen, the per-room enemy-defeat roll, where keys are scattered, and the solvability invariant. What follows here is the captured Valley layout: a transcribed instance of that rule, not the rule itself. Where the two appear to disagree, field-gates wins and this page is the example.
Each outward edge (away from the start) carries a gate value; the way-back edge is always open:
0— open, walk through.4— enemy-defeat gate: locked while the room holds live enemies, opens on clear. The common case.1/2— key gate: needs that many keys.
A field places exactly as many keys as its gates require, on cells
reachable before the gate — so a layout is always solvable without backtracking
to an unreachable room. Valley: a 2-key gate B1 → C1, with both keys
on B2. This balance is a tested invariant.
Two psz-godot divergences from the captured instance, both recorded on the
normative page: generated fields offer the scatter pool's dead-end rooms
first (#639), and a key drops when its room is cleared
rather than standing on the ground at load — the authored slots still decide
where it stands (Key Placement).
In the captured Valley above the keys sit on B2 the moment the room
draws; in a generated field they appear when B2's last enemy dies.
Enemy population
Spawns are drawn from the measured wave tables, not authored by hand. Each room
code has an allowed list of wave templates with selection weights
(psz-re enemy_room_assignment); each template is a fixed set of enemy
ids (enemy_wave_templates). The start and goal rooms carry no
wave and spawn nothing. The exact per-instance roll (which allowed
template a room draws) is not yet decoded, so the builder draws
deterministically against the measured weights — preserving the "mostly ordinary
fights, occasional rare" texture rather than making every instance identical.
A room runs 1–3 waves, each an independent draw from this pool;
the wave count and its trigger timing are governed by
Enemy Waves.
psz-re internal enemy names map to this engine's ids: snake→garapython,
vulture→vulkure, lion→helion, lion_rare→blaze_helion,
hyena→grimble, lizard→ghowl, rappy→rappy.
Objects and traps are AUTHORED, not generated
Boxes, walls and traps are not scattered by a placement pass.
Every one is authored per room in the original's
set/<NN>/<v>/<room>/*.rel, with a room-local
(x, y, z) in the same frame as room_doorways.json and a
16-bit facing. The table is data/re_reference/room_objects.json
(290 rooms — every cell of every field quest resolves), imported by
scripts/tools/refield/import_re_objects.py. A port MUST place them
as authored; it MUST NOT ring-scatter or raycast-and-snap.
A set file is six groups, and which of them a room instance builds is the one part that is rolled:
- One of five layout masks picks which of groups 0–4 are built.
The index is a
rand(100)draw against a weight row banded by the room's depth in the tree. A mask is eligible only if every group it names is non-empty; when the drawn index is not eligible, the highest eligible index at or below 3 wins, else 0. - Groups 0–4 are then built verbatim. Nothing is sampled.
- Group 5 is the trap group — 99.5% of the original's trap pool
sits in it — and it is rolled:
rand(100)against[40, 20, 20, 20]gives a count of 0–3, the group is shuffled, and that many are taken. So 40% of rooms roll no group-5 object at all. An implementation that built 0–4 and stopped would place almost no traps. - A group is truncated at 20 records and a room stops at 20 objects.
Every draw goes through the generator's seeded RNG, so a seed reproduces a field exactly. Authored walls are in the table and are deliberately not placed — see the approximation list below.
Traps
The original has five field trap types. Four are contact traps that share one behaviour and differ only in damage; the fifth — the elemental family — is its own thing and is the interesting one.
| Family | Ids | Behaviour |
|---|---|---|
| Contact | needler_trap (25 dmg), burn_trap (50),
gun_trap, capture_trap | Visible floor furniture. Damage on contact; capture holds the player. The 25 and 50 are measured constants from the original's parameter blocks. |
| Elemental | heal_trap, heat_trap, light_trap,
ice_trap | Invisible, proximity-armed, fused — see below. |
The elemental field trap and the player's trap item are the same object.
psz-re decoded the original's class: factory index 19 / type 0x1C13,
and the only thing separating the two families is one byte in the object's
parameter block. This engine models that with TrapBall.field_placed
rather than a second script.
An authored elemental trap MUST:
- be invisible while dormant — the original's factory table gives it no model at all;
- arm when a player comes within 4.0 units, then rise 2.5 units out of the ground and become visible;
- run a fuse of 45 / 30 / 15 frames on normal / hard / v_hard — the original gets faster, not stronger, with difficulty — and detonate on whoever is still inside;
- apply its element: heat → burn, ice → freeze, light → confusion (stunned stands in), heal → restore 50% of max HP. About 10% of the original's authored elemental traps are heal traps, so not every field trap is hostile.
A player-placed trap of the same element is always visible to its owner, arms on a timer, waits for a valid target, and then runs its own measured fuse — 150 frames for heal, 75 for the rest.
Seeing a dormant trap
A dormant elemental trap MUST be drawn when either holds:
- the active character is a CAST — traps are the CAST counterpart to techniques, and a CAST sees them natively; or
- Trap Vision is active. The item's own text is the contract: "Temporarily allows any non-Cast race to see traps." Using one as a CAST is allowed but redundant, and a second one extends rather than stacks.
Trap Vision's duration is not measured — psz-re found no such timer anywhere in the trap class, which also means the reveal is a renderer-side concern in the original and carries no gameplay coupling.
The boss room
The z section is a single arena. Valley's is s01z_na1, and
the assignment table's sole s01z entry is boss_dragon — so
Valley's boss is Reyburn. See
Boss Behavior. Clearing the boss spawns the
return-to-city warp (the existing type: "boss" section handling).
What is deliberately approximated
- The generator algorithm itself is located in the decomp (psz-re
FUN_02082a98; room count =min + rand(max−min)) but not yet ported. Current fields are static fixtures transcribed from observed layouts — parity of output, not of the RNG. (issue #562 Phase 3.) - Enemy spawn positions within a room are not decoded; the builder rings them around room centre. Object and trap positions are no longer approximated — see above.
- Which allowed wave a room rolls is our own weighted pick (see above).
- Authored walls are not placed. Their positions are as measured as the boxes; the reason is gameplay, not data. A wall blocks, the free-roam autopilot's walk backbone cannot attack one, and a wall across the line between two doorways is a soft-lock rather than a cosmetic complaint. Gated behind
FieldPopulation.PLACE_WALLSuntil the nav harness routes around blockers. - What arms a field trap is not decoded. psz-re reads the state machine but not the transition out of dormant, so "a player walks close" is the obvious reading of a thing that rises out of the ground on a fuse — it is not measured, and neither is its radius. The 4.0 units above is the one radius the original publishes (the heal element's player scan), reused.
- A field trap's damage is ours. The original's elemental trap carries no per-instance parameters at all, so there is no number to port.
- Gun-trap damage varies per instance in the original and the field is not published, so it takes the contact trap's default.
- The four contact traps share one model until dedicated meshes land.