Skip to content

The Frontend Menu Machine

codingncaffeine edited this page Jul 17, 2026 · 2 revisions

The Frontend Menu Machine

Dungeon Siege's main menu is not a 2D UI. It is a miniature 3D machine: nine skinned meshes sharing one coordinate space, posed and morphed by skeletal animation clips, textured from swap tables, and scored with its own machinery sound set. Screens are poses of the machine; navigating between screens runs it — gears turn, planks fold, a title drum rolls, a window door slides open.

This page documents how the machine works, and — because it took a long stretch of blind iteration before we learned to measure instead of guess — how each piece was actually figured out. Everything here is reproducible from a retail install with the siegefx CLI; nothing comes from leaked source. If you are reimplementing another vintage engine's 3D menu, the workflow section at the bottom is the transferable part.

1. The parts

All meshes live in Objects.dsres under art/meshes/gui/front_end/menus/main/ with the prefix m_gui_fe_m_mn_3d_; their animation clips sit next door under art/animations/.../a_gui_fe_m_mn_3d_<mesh>_<clip>.prs.

mesh bytes role
backdrop 9,229 the 3×3 stone-machinery wall; its bbox is the screen frame (X ±1.64, Y ±1.64 — square)
leftside 170,765 left gear pillar + door tiles + the door-socket gear/piston ornaments
rightside 187,984 right pillar with the sliding hero-window door (ASP v2.2 — one BTRI header word, not two)
mainmenu 149,868 inner panel + chains + the title drum (§3)
menubars 200,889 the wood button rows + their label flaps (§3)
backbutton 34,825 the bottom block: EXIT ↔ BACK ↔ PREVIOUS+NEXT morphs (§3)
heromenu 27,425 hero-creator spinner column
logo 6,769 title splash: wordmark + the sword that drops into the log
loadmap 44,925 the Load Game window: marble frame + world map + selector spikes

Rendering is painter's algorithm — depth test off, draw order decides layering — with backface culling on. The culling is load-bearing: every "this plate shows, that plate doesn't" mechanism in §3 works by rotating geometry to face toward or away from the camera.

Projection receipt: letterbox the viewport to the backdrop box's aspect, scale by 1.30 overscan (retail framed only the central 1/1.3 of the box). At 800×600 that lands on 237.7 px per mesh unit: screen_x = 400 + mesh_x·237.7, screen_y = 300 − mesh_y·237.7. Memorize that pair — it converts any traced bone position straight into screenshot pixels, and it is how every placement claim below was checked. The 2D widget layer (.gas rects, authored in an 800×600 interface space) maps 1:1 onto that central region — not onto the overscanned box; mapping it wrong inflates every widget by 1.3× and was one of our earliest layout disasters.

2. Screens are poses, transitions are clips

Clip names follow one grammar: <mesh>_<from>2<to> over a set of two-letter state codes. The engine hardcodes the state machine (there is no frontend skrit — we searched; zero matches in either tank), so the codes had to be decoded from the clips themselves:

code screen
mm main menu
sp single-player submenu
sng choose hero (the character creator)
cd choose difficulty
lg load game
lm load map (multiplayer world picker)
mp multiplayer provider menu
dsm multiplayer session/staging tree
e / b / pn / ac backbutton poses: EXIT / BACK / PREVIOUS+NEXT / ACCEPT

The sng/cd row is the hard-won one. For months we read cd as "character design" and held the creator's title drum at mainmenu_sng2cd@1 — and kept finding either a blank plaque or the word DIFFICULTY where CHOOSE HERO belonged, then "fixing" it with subset masks. The decoding receipt is one command:

siegefx asp trace-pose m_gui_fe_m_mn_3d_mainmenu.asp a_gui_fe_m_mn_3d_mainmenu_sng2cd.prs 1.0
   → PanelBASE3 (CHOOSE HERO)  base Y=1.160, tip Y=1.374   — tip UP   → backface-culled
   → PanelBASE7 (DIFFICULTY)   base Y=1.162, tip Y=0.952   — tip DOWN → visible
siegefx asp trace-pose ... mainmenu_sp2sng.prs 1.0
   → PanelBASE3 tip Y=1.135 — tip DOWN → CHOOSE HERO visible

So sng2cd rolls the drum from the hero screen to the difficulty screen, and the creator's true settled pose is sp2sng@1. One trace ended a mystery that had survived multiple rewrites. The general lesson: when two states show the wrong content in each other's slots, suspect the state labels before the bones.

3. The three flip mechanisms

Everything the menu "changes" between screens is one of three tricks, all driven by bones + backface culling:

The title drum (mainmenu). The title plaque is a rolodex on the spindle: ten PanelBASE/TIP bones each carry one plate, textured from two atlases split left/right (text-01l/r.raw rows: MAIN MENU · SINGLE PLAYER · CHOOSE HERO · LOAD GAME · OPTIONS; text-02l/r.raw: the DIFFICULTY / LOAD MAP / multiplayer tree). The plate whose tip hangs below the spindle faces the camera; every other plate faces away and is culled. Comparing base bone positions makes two plates look co-located and sends you chasing phantom "bleed" bugs — the bases all sit on the spindle. Compare tips.

The label flaps (menubars). 17 subsets: panel chrome, five wood rows, and label quads skinned to each screen's row bones. Rows fold like planks between states; because the label quads ride the same bones, drawing the text subsets with the transition clip makes the letters fold with the wood, and the folded-away faces cull themselves. Each row's label subsets come in pairs — the even ones sample text-menubars1.raw and are the ones to draw; the odd ones bind a texture whose on-disk content belongs to a different screen (an engine-side texture indirection we have not reverse-engineered; skipping the odd subsets is the working answer).

The backbutton morphs (backbutton, internal name exitback, 11 subsets, 13 bones). The little block at the bottom is four screens' worth of controls in one mesh: the EXIT↔BACK flip is two plates on a spindle (e2b/b2e — tip direction again), and BACK↔PREVIOUS+NEXT telescopes the side plates outward (b2pn: PrevTip X→−0.80, NextTip X→+0.80, landing the text quads exactly over the .gas hit rects at screen x 262–339 / 461–562). The part that fooled us twice: at the BACK pose, the folded-in prev/next plates are the ball-knob handles flanking the plate. Masking them off "because art_mapping only lists subsets 4+8 for this widget" strips the button of its surrounding design — art_mapping.gas lists the hover-swap subsets, not the full chrome roster. Two screens shipped bare BACK plates for that reason before the retail screenshot comparison caught it.

4. Draw order: authored subset order is the layer order — except when it isn't

With depth off, subsets paint in authored order, and DS1 authored them knowing that. Two standing traps, plus the case study that finally taught us to respect the authored order:

  • Shadow subsets — nearly every chrome mesh ships a translucent shadows subset authored last (leftside 5, mainmenu 5, heromenu 12, backbutton 10, menubars 16, loadmap 2). Drawn in place, it paints grey haze over the content. It belongs in a pre-pass underneath (or masked off where it lands across UI).
  • Behind-the-body rigsloadmap authors its silver pole rig after the window body, but the poles run behind the frame; only the tips and hanging knobs should peek out. Pass order for that screen: shadow → poles → body.
  • The socket gears (case study). The door tiles of leftside have two round sockets that, in retail, hold ornate gear wheels with gold cog trim, sitting right beside the BACK button. Ours were empty rings for months. The cause: an early "fix" reordered the pillar draw into body-then-doors because doors-over-columns looked wrong at the seam — which buried subset 4 (background-02 texture, DoorBase/PistonBase bones), the gear-and-piston ornament layer that DS1 paints on top of the doors. The receipt that settled it: trace-pose puts subset 4's bbox at mesh (±0.33, −0.84) ≈ interface (322/476, 499) — exactly the sockets — and a retail screenshot shows the gears over the door tile. Authored order restored (doors 0,1 → columns 2 → machinery 3 → ornaments 4), shadows in an under-pass, and the machine got its gears back. When a subset seems misplaced, the answer is a trace and a retail screenshot, not a reorder.

The right pillar deserves its own line: rightside.asp is the only frontend mesh saved as ASP v2.2, whose BTRI section stores one u32 per subtexture (cornerStart) where v2.3 stores two. Until the parser learned that, the mesh rendered stretched, and we shipped a mirrored left pillar in its place — symmetric, and wrong: DS1's right pillar is asymmetric, carrying the framed window arch whose door slides open (rightside_open, 3.33s) to reveal the 3D hero preview on the creator screen.

5. Timing: every clip at its own speed, and no idle anywhere

The clip catalog (siegefx prs info over all 69 frontend clips) clusters into exactly four lengths, and the distribution is the choreography:

length clips role
0.6667s mainmenu_up/down, menubars_alldown/allup the chrome rolling away/back (not yet used — see §8)
1.5s menubars_flyin rows drop in (boot)
1.3333–1.6667s all mainmenu_*2*, most menubars_*2*, loadmap_down/up, logo-enter (2.17s) the panel morphs — what you read and click
3.3333s all backbutton_*2*, rightside_open/close, leftside/rightside_flyin the machinery flourishes — gears, doors, block flips

Two facts fall out of that table. First, transitions must run each clip at its authored length: normalizing them to one shared window (our original implementation) played the 3.33s backbutton flip at 3.3× speed and cut the boot gear flourish off halfway — the menu read as "nothing animates." The engine now keeps a single since-transition clock; each mesh evaluates its clip at native speed and parks on its end frame; a transition state lasts as long as its panel clips (input unlocks at ~1.67s) while the long machinery clips keep turning as tails into the settled screen — the settled state draws the same clip on the same clock, so the motion completes without a pop. Arriving somewhere from two directions picks the tail clip by arrival path (Single Player draws the block as e2b when you came from Main Menu, pn2b when you backed out of Load Game).

Second, there is no authored idle motion anywhere: every *_default and *_begin clip is a single-key static pose (23 of 23 bones, one rot + one pos key each). The pillar gears spin during flyin (58 rotation keys on the small gears, 34 on the big ones, a 27-key piston pump), the window gears during owin/cwin — and then the machine stops. A menu that idles still is authentic; inventing an idle loop would not be.

The boot sequence is the one place the timing is authored across files: frontend_lights.gas sets to_main_menu ramp_duration = 6.0, the logo drop is 2.17s + exit 0.58s, the fly-in flourish 3.33s — and the assembly sound s_e_frontend_menu_enter.wav runs 5.71s. The numbers interlock; that agreement is what confirmed the sound mapping (§6). Related behavior decision: skipping the intro with Esc (or nointro) lands on the fly-in, never past it — the chrome assembling is the menu arriving, so it plays on every boot path.

6. Sound: the machine has its own foley

Sound.dsres /sound/effects/ ships a dedicated frontend set. Durations are header math (22050 Hz stereo 16-bit), which is also how the roles were assigned — the packed retail exe surrenders no trigger strings, so names + durations + the authored 6.0s ramp are the evidence:

sample duration wired to
s_e_frontend_menu_enter 5.71s boot fly-in (the machine assembling; ≈ the 6.0s ramp)
s_e_frontend_menu_open 5.71s (different audio) submenu folding open (main → SP / MP)
s_e_frontend_menu_close 4.89s submenu unwinding (SP / MP → main)
s_e_frontend_menu_roll 2.99s inner screen rolls (load game, creator, difficulty; ≈ the 3.33s clip family)
s_e_frontend_logo_flyin / flyout sword drop / withdraw
s_e_frontend_big/small/tiny/arrow_button clicks (main buttons / dialogs / spinners)

One implementation stumble is worth recording because it silenced the whole system: our state-edge listener compared the frontend state before and after the state machine's tick — but click-driven transitions set their state from the input handlers, after that comparison in the frame. Every click-initiated transition was invisible to it, so no transition ever made a sound (session logs showed only settle edges like MainMenuToSp → SinglePlayer, never entries). The fix is structural: diff against the last state the listener processed, not a same-frame before/after pair. If you build an edge-driven anything, make sure every writer of the state is upstream of the edge detector — or diff against memory.

7. Textures: one mesh, many faces

ui/config/art_mapping/art_mapping.gas (Logic.dsres, 7,858 bytes of plain text) is the master key: for every widget, per mouse state, it lists which mesh subsets rebind to which texture ([button_sp_back] mouseover → 4 = exitback-up; 8 = text-small-up, etc.). The engine prepends b_gui_fe_m_mn_3d_ and appends .raw; subset keys correspond to the -mapN suffixes on the mesh's authored texture names, which all alias one underlying atlas after the suffix is stripped. Every engraved label in the frontend — EXIT, BACK, PREVIOUS, NEXT, HERO, NAME, the creator row names — is baked art in text-small.raw, not rendered text; substituting a font renderer reads instantly as wrong. We burned an estimated two hundred thousand tokens' worth of UV-crop guessing on the EXIT button alone before finding this file. Read the swap table first. It has been the first step of every screen since, and no screen built that way has needed a revisit.

Two perennial coordinate traps: .raw stores rows bottom-up (mentally V-flip every atlas), and .gas uvcoords are bottom-up GL V while our icon path takes visual top-down V (visual_v0 = 1 − gas_v1).

8. What's still open

  • mainmenu_up/down + menubars_alldown/allup (0.67s) are almost certainly the chrome rolling away when a game launches — currently unused; the menu cuts to loading.
  • Esc inside submenus should probably act as Back (retail behavior unverified); today it is inert there.
  • The multiplayer map-chooser (lm/dsm tree, loadmap + mainmenu_mp2dsm) is unbuilt.
  • The main menu's EXIT is still drawn as a rect-mapped widget at a hand-tuned position rather than the chrome block at its authored spot (with backbutton_flyin on boot) — a fidelity/regression tradeoff not yet taken.

9. How we actually figured this out (the transferable part)

The early period was expensive: guessing rects from screenshots, iterating UV crops, re-deriving per-button mechanics one visual bug at a time, and "fixing" correct data with masks. What replaced it is a measurement loop, and it is the reason the later screens (Load Game, Difficulty, Multiplayer) landed in single sessions:

  1. Get the retail reference. A screenshot of the real screen, and the conversion constant (237.7 px/unit at 800×600) to move between pixels and mesh space.
  2. Read the data that already answers the question. The screen's interface .gas (widget rects, notify names), art_mapping.gas (texture swaps), frontend_lights.gas (state names, ramp timings). The answer is usually shipped in plain text.
  3. Trace before writing code. siegefx asp subsets (which bones own which subset, which atlas it samples), siegefx asp uv-by-bone (which atlas row each bone carries), siegefx asp trace-pose <mesh> <clip> <t> (post-skin bounding boxes and bone positions at any clip time — the single most informative command in this entire effort), siegefx prs info/compare (clip lengths, key counts, which bones actually move).
  4. Render receipts, not builds. SiegeFX.Runtime --frontend-shot <Logic> <Objects> <state> [--t=SECONDS] renders any menu state — including mid-transition frames — to a PNG through the real renderer in a hidden window (test-all option 115). Comparing two --t frames proves a gear turns; comparing against the retail screenshot proves placement. No booting, no clicking through menus, no "I think it looked different."
  5. When a still can't answer it (motion feel, audio), a human plays it. The receipts shrink that list to the things that genuinely need eyes and ears.

The pattern generalizes to any data-driven vintage engine: the shipped bytes are the specification, the original renderer's conventions (draw order, culling, coordinate handedness) are part of that specification, and every hour spent building a tracer pays back the first time it turns a week of eyeballing into one command.

It is also, deliberately, a provenance ledger. Every fact on this page traces to one of three sources: bytes shipped in a retail install (meshes, clips, gas files, wavs — all quoted with sizes and readable with the CLI), black-box observation of the retail game running (screenshots, timing), or arithmetic over the two. That chain — retail data → runnable receipt → implementation — is the clean-room position in practice, and pages like this one are what make it auditable rather than asserted.

Clone this wiki locally