At a glance

Input
A disc image in the browser tab. Save-state captures only refine camera anchors, the fog colour and the script-placed actors
Kingdom bundles
PROT 0086 Drake / 0245 Sebucus / 0392 Karisto; the walk .MAP is the entry two slots before each block (a fixed 0x12000-byte footprint)
Ocean
A static 4bpp tile at VRAM (768, 256) whose first 16 palette entries at row (0, 506) are rewritten from a 13-frame table
Fog
Retail's 2048-entry per-Z scalar LUT, found by content scan in SCUS_942.54, mixed toward a per-kingdom haze colour
Code
legaia_web_viewer::{build_walk_ground, build_walk_placements, ocean, sentinel_placements, scene_export}; site/js/world-overview-app.js, webgl-tmd.js, webgl-math.js
Confidence
Heightfield, stamps, ocean and fog LUT: Confirmed against the engine and save-state VRAM. Unplaced-mesh layout and global-pool placeholders are viewer conventions, not retail
Used by
World overview · VR mode · Asset viewer (full-map mode)
Render passes of the world-overview viewer in draw order: ocean backdrop, continent heightfield, decoration stamps, placed landmarks, then the fog mix in the fragment shader; one CLUT row feeds both the ocean quad and every water cell ocean backdropflat quad, y = 0 heightfieldwalk .MAP floors decorationstrees, mountains landmarkstowers, bridges fog mixlut[z] to haze CLUT row (0, 506), 13 frames
Draw order of the assembled view. One palette-row rewrite per step shimmers both the sea and every water cell, because they sample the same 16 entries.

What the viewer has to rebuild

Retail leaves a lot to runtime code. Each kingdom's bundle ships a pack of landmark meshes (the "slot-1 TMDs"; TMD is the game's mesh format) that the scene's MAN - its script-and-placement container - only partly positions; the rest are placed by the field VM, the game's script interpreter. Five pieces close that gap:

PieceSourceSection
Continent groundWalk .MAP floor grid, generated the way the game doesheightfield
Landmarks and decorationsWalk-map per-cell recordsstamps
FogSCUS LUT + per-kingdom haze colourfog, colour
OceanKingdom bundle slot 0 tile + 13-frame CLUT tableocean
Script-placed actorsSave-state captures (the disc cannot say where they rest)sentinels, anchors

Continent ground heightfield

The green-and-brown ground is not a mesh on the disc. It is generated from the walkability map's floor heights, one cell at a time, exactly as the game builds it on entry to the overworld: a heightfield in the col × 128 world frame, textured per cell from a terrain-type-keyed atlas (grass, mountain, water, forest). The floor-height LUT is 16 signed halfwords at the start of the kingdom's MAN; the surface math is the engine's bilinear floor sampler, shared with the native world-map render.

Code: legaia_asset::field_objects::build_walk_heightfield, wrapped for the browser by legaia_web_viewer::build_walk_ground. The model and per-cell texturing are pinned on world map.

Landmarks and decorations

Towers, castles and bridges are stamped onto specific tiles; forests and mountain ranges are the same few small meshes stamped dozens of times. Both layers come straight from the walk map's per-cell records and draw in the same world frame as the ground.

LayerRecord ruleCount (Drake / Sebucus / Karisto)
Placed landmarksObjects with the placed flag 0x4, stamped on occupied tiles5 / 6 / 17
DecorationsWalk-visible cells with a nonzero mesh slot at +0x10, the mesh-drawn bit 0x2 and no placed flag~295 / ~240 / ~210
Not decorationsNonzero mesh slot without the 0x2 bit (the riverbank record family, identical across kingdoms) - stamping them tiles a wall down every river-
  • Height. World Y = −lut[floor_nibble] + y_off, so each stamp sits on the heightfield. The JS negates that anchor before handing it to the model builder: the ground bakes −lut into its vertices and lands at +lut under the shared (1, −1, 1) flip, so an un-negated anchor buries a stamp by 2 × (lut − y_off) - whole cave entrances vanish on Drake's mountain cells.
  • Yaw. Each record carries an authored yaw (4096 per turn) that retail copies into the actor's rotation triple; the Sebucus island bridges' quarter turns and the trees' variety come from it. Retail's pure-Y matrix maps local +Z to (sin, 0, cos), the opposite sense of the JS helper, hence rotY = −(rot & 0xFFF) · π / 2048.
  • Cutout. The PSX rule (BGR555 zero with STP clear discards) makes the crossed-quad tree billboards read as foliage; the kingdom's real VRAM image is uploaded and CLUTs resolve like retail.
  • One suppressed stamp. Placed objects are script-managed - retail gates each spawn on a MAN interaction record and runs its leading ops inline, so a record's resting position is not always its grid cell. Drake's record-349 golden bridge sits over the river in the grid but retail shows the one bridge at the record-441 road crossing; WALK_STAMP_SUPPRESS drops it by (mesh, x, z).

Code: legaia_web_viewer::build_walk_placements over parse_placements + parse_walk_decorations. The disc-gated walk_placements_parity.rs asserts the viewer's placements equal the engine's for all three kingdoms; window.__woWalkStamps exposes the queued stamp list for headless checks.

Distance-cue fog

Far-off terrain fades into a coloured haze. Retail keeps two things separate and the shader mirrors the split: a per-Z scalar LUT (2048 halfwords climbing from 0 at near-Z to ~0x1FF at far-Z) and a haze colour loaded per kingdom into the GTE's far-colour registers.

StageRetailWebGL
Depth termZ >> shift from the GTE screen-space pipelineexp2(−zShift) × dist(world, camera), clamped and normalised to [0, 1]
Scalarlut[z], applied by the overlay as a per-vertex nudgelut[t × 2047] / 511
ColourFar colour via ctc2 at world-map entrymix(lit, haze, factor)
LUT sourceSCUS, offset 0x05FCC0 on the USA buildfog_lut::find content scan (monotone ramp, leading zeros, saturating tail) - regional variants need no hardcoding; without a hit the mix factor is the depth term itself

For a top-down camera the two depth terms agree up to a constant; the orbit-camera mesh inspector hides the fog toggle because they do not.

History: two misreadings of the fog registers

The lwc2 load beside the LUT is the IR0 depth-cue factor, not the fog colour - the colour arrives through ctc2. And the LUT entries are per-Z scalars, not RGB tints; treating them as tints produces "richer textures", not fog.

Per-kingdom fog colour

The haze colour is not a constant in the executable. An "atmosphere" actor runs a small per-kingdom script every frame that interpolates the haze RGB into the actor's colour word; the primitive dispatcher then splits that word into bytes, scales 8-bit to 12-bit and writes the three GTE far-colour registers. The capture script surfaces the live RGB of any actor running that tick as fog_color in site/world-overview.json; the viewer prefers it over the hand-tuned KINGDOM_FOG_TINT fallback.

Ocean tile and its 13-frame palette animation

The water texture never changes - its palette does. Sixteen colours cycle through thirteen frames and a bright peak walks across them, so the same pixels read as rolling waves.

AssetWhereDetail
TextureKingdom bundle slot 0 (TIM list), image at VRAM (768, 256)4bpp, 256 × 256 logical pixels; the wave ramp fills the top-left 96 × 96, the rest is shared page padding. Bytes differ per kingdom
Base CLUTSame TIM, row (0, 506)256 BGR555 entries; only the first 16 are animated, the rest belong to other tiles on the row
Animation tableSlot 0, CLUT-only TIM records (slots 3-5 Sebucus / Karisto, 10-15 Drake), first frame 0x54 bytes in13 frames × 16 entries = 416 bytes, byte-identical across the three kingdoms; the peak 0x3D05 walks indices 0..7
CadenceKingdom bundle slot-5 CLUT-walk tableA 16 × 1 copy onto (0, 506) every 9 vsyncs; the viewer uses 0.1 s per frame on a wall clock

The stepping table is the slot-5 CLUT-walk table the live engine steps directly - not the script-driven CLUT-cell family, which only carries the row-498 park one-shots. Code: legaia_web_viewer::ocean::find_ocean_assets (disc-gated test ocean_assets.rs across all three kingdoms). Before a disc is loaded the sea is a solid tint from ocean_color, CLUT-sampled out of a save state.

Shader and camera

  • One palette write per step. Each animation step writes the frame's 16 entries into the VRAM texture at (0, 506) - the row every water cell of the heightfield samples - so open sea and terrain-embedded water shimmer phase-locked as one layer. It advances even with the backdrop pass off.
  • Backdrop. A flat quad at y = 0 extending past the continent, drawn first so the depth test occludes it; the "ocean" checkbox toggles only this pass.
  • Perspective orbit. The retail world map is genuinely 3D (GTE perspective, confirmed by moving the camera with GameShark). buildWorldOrbitVp orbits a ground-plane target on yaw and pitch; zoom keeps its orthographic meaning so wheel and pan behave the same at any tilt. Left-drag pans, right- or shift-drag orbits, "lock to retail top-view" flattens the pitch on the captured anchor. Both camera paths keep the retail screen basis: the map rotated 180° and mirrored horizontally.

Script-placed actors

Some MAN placement records carry no real coordinate: the encoded pair (0x7F, 0x7F) decodes to the world's north-east corner and means "a script will put me somewhere". Retail's placement walker sees a leading op 0x24 / 0x25 in the record's trailing bytes and enters a field-VM loop that writes the actor's position from per-record state - bytecode that branches on actor type, story flags and overlay tables, so it is not resolved statically.

ToolRole
scripts/mednafen/resolve_bulk_terrain.pyWalks every actor list in a save state, captures live positions and the mesh chain, resolves the mesh back to the kingdom pack by reverse magic search; tags bulk_terrain vs man_actor
scripts/asset-investigation/extract-world-placements.pyMerges the result into site/world-overview.json as bulk_terrain_placements; the regression digest hashes only the static placements
crates/web-viewer::sentinel_placementsRust port of the RAM-side resolver for downstream callers
scripts/pcsx-redux/resolve_actor_tmds.pyDrake's 12 live actor placements (gates, bridge towers, the small castle) into site/world-overview-live.json

Global-pool placeholders. Placements with tmd_slot ≥ 0xF0 reference the global mesh pool rather than the kingdom pack. That pool is not bundled, so the viewer stamps the pack's slot-0 mesh at the decoded coordinate, tags it global_pool, and reports both the reference count and the placeholder count so the gap stays visible.

Unplaced slot-1 meshes. Each kingdom ships more landmark meshes than its table positions. The "show unplaced" toggle lays the leftovers out beside the continent by class (slot1_classification.toml): landmarks in a row to the south, decorations to the north, ground tiles in a grid to the west, unknowns to the east, NPC tokens hidden. Each mesh is anchored on its bounding-box centroid and scaled to a per-class footprint so the rows read at one size; "normalize unplaced" turns both transforms off for ground-truthing.

Camera anchors

TableSourceUsed for
KINGDOM_CAMLoad-time map-origin globals, decoded by mednafen-state world-map-cameraDefault view when a kingdom tab opens
world-overview.json[kingdom].topview_camPer-kingdom capture written by resolve_bulk_terrain.py"Lock to retail top-view", preferred
KINGDOM_TOPVIEW_CAMHand-tunedFallback for the lock

The captured anchor is the spawn origin the dev-menu top-view also enters from before any scrolling, which is why a walk-view capture is a good enough lock target.

Details: how the static placement lists are captured
  1. Read the active CDNAME label at 0x80084548 and confirm the kingdom (map01 / map02 / map03).
  2. Walk the seven actor-list heads at 0x8007C34C..0x8007C36C, chaining on each actor's +0x00 next pointer.
  3. Read position as three i16 at +0x14, flags at +0x10, the id byte at +0x50.
  4. Drop zero-position entries (system actors not placed in world space).

Continent .glb export

"Download .glb" bakes the assembled view - heightfield plus every stamp on screen - into one binary glTF through the shared scene_export session (bake in legaia_asset::scene_gltf). The exporter receives the same mesh buffers and (translation, rotY, scale) triples the page renders with, so the file matches the screen. The PSX VRAM + CLUT indirection is baked into one RGBA atlas, a 256 × 256 tile per distinct (cba, page) pair, nearest-sampled, with alpha for the cutout rule. The ocean is a screen effect and is excluded. The asset viewer's town export rides the same session; the monster export is the sibling monster_gltf, which also carries animations.

How we know

FunctionAddressWhat it provesDump / probe
Floor samplerFUN_80019278Bilinear floor height from the walk grid - the heightfield surfaceghidra/scripts/funcs/80019278.txt
Placement stamperFUN_8003A55CPlaced-flag objects, yaw copy into the rotation triple, MAN-gated spawns8003a55c.txt
MAN placement walkerFUN_8003A1E4The 0x7F sentinel and the field-VM prescript loop8003a1e4.txt
Pure-Y rotationFUN_80026988Yaw sense (local +Z to (sin, 0, cos))80026988.txt
Fog LUT applyoverlay 0x801F7644..0x801F8690Per-vertex scalar nudge from the 2048-entry LUT at gp−0x2BCworld-map overlay dump
Far colourFUN_80043390 / FUN_801E3E00Three ctc2 writes; the atmosphere actor's per-frame RGB80043390.txt, overlay_801e3e00.txt
Ocean CLUT stepslot-5 CLUT-walk table16 × 1 copy onto (0, 506) every 9 vsyncslegaia_asset::clut_walk; prim-trace clut=0x7E80 cluster
Camera origin_DAT_80089118 / _DAT_80089120Spawn anchor per kingdommednafen-state world-map-camera

See also