# eqoa-rust-client-vanilla: an EQOA client in Rust, without mods A native client for *EverQuest Online Adventures: Frontiers* that owns its own data structures, rather than running a PS2 inside a program the way eqoa-port (the decomp as a native program) does. The goal is every platform, phones included (wgpu: Vulkan, Metal, DX12, WebGPU), 64-bit, with the feel of the original matched exactly. **No game data is in this repository**, and none is downloaded: you need your own Frontiers disc image (natively the client asks for it the first time and keeps its place, `discs.rs`; in a checkout `run/eqoa.iso`, or `$EQOA_ISO`). What the client reads out of the game's code modules, CLIENT (the start menu, `gWorldInfo`, the race cameras) and SUPPORT (the HUD's atlas, the UI sounds' volumes), it reads at run time, the way eqoa-port's gendata does. The disc holds those modules only DNAS-encrypted, so crate `dnas` unpacks them off it with the disc's own DNAS library, on threads of their own while the session starts, and keeps them for the next run (`eqoa-rs/dnas` in the platform's cache directory, or `$EQOA_CACHE`; in a page, the origin's storage). The player's decrypted images serve instead when given (`run/client.bin` or `$EQOA_CLIENT`, `run/support.bin` or `$EQOA_SUPPORT`). ## Vanilla The game as the PS2 plays it, with the client's own additions for a PC, a phone or a browser (each described where it lives below): - **Widescreen**: the HUD at a wide window's edges; the start menu, its windows and the merchant's window the game's 4:3 picture in the middle, closed at its edges. - **A frame rate untethered** from the game's 30 frames a second (the game's logic still steps at 30). - **The mouse and the touch screen.** - **The keyboard**, with WASD walking, and the options' **Controls** page: every key and button can be moved. - **1,000 lines of chat history** (the game's ring holds 31). - **The start menu's windows over the world**, with no green backdrop behind them. - **The Extended options page**: Level of Detail, Brightness, Frame Rate, Camera Distance, Controller Focus. - **Several windows at once** (multiboxing): each window reads its own controller, the first window the first controller, the second the next, in front or not; the Extended page's Controller Focus picks another (windows that pick the same one are all driven by it), or follows the window in front. - **The map** (M), drawn from the player's own disc for every world, on any server. - **Network Setup** on the title: the address of the server to play on (on a PS2 it starts the console's network utility). - **Updates**: a downloaded build puts the release's newer one in its place as it starts, before the title. - **Problem and crash reports**: F3, or the HUD's Report a bug, sends a note and a picture of the screen to the report board; a crash is offered to be sent at the next start. Nothing is sent unless the player says so. ## Playing a release One build is published, **dev**: rebuilt from `master` on every push, for Linux (x86-64), Windows (x86-64) and macOS, on the [releases page](https://github.com/DabDavis/eqoa-rust-client-vanilla/releases/tag/dev). Each build replaces the one before, so only the newest can be downloaded; the release page's update notes (`UPDATE-NOTES.md`) say what each build changed. It is the `eqoa` program, in an archive with its README and licence, or on its own (`eqoa-vanilla-linux-x86_64`, `eqoa-vanilla-windows-x86_64.exe`, `eqoa-vanilla-macos`: on Linux and macOS `chmod +x` it after the download). You need your own *Frontiers* disc image (SLUS-20744) and an account on a server (or make one on Station's login screen, where the server allows it). Start `eqoa` (`eqoa.exe` on Windows; `./eqoa` on Linux and macOS). The first time, it asks for the disc image in the desktop's own file dialog, whatever the image is called and wherever it is, checks that it is *Frontiers* (by the program its SYSTEM.CNF starts, as the page does) and keeps its place for the next time. `eqoa /path/to/image.iso`, or the image dropped on `eqoa.exe`, plays and keeps that image instead, and `EQOA_ISO=/path/to/image.iso` names one for a run. The game starts as the console does: the loading screens, the legal screen and the licence, then the title. On the title, **Network Setup** takes the server's address (a host name or an IPv4 address, kept for the next time); **Play** brings Station's login screen (your account and password; Save Account keeps both, the password in plain text, as the memory card kept them), the server's news, the server list, character select (with creation on an empty slot) and the world. Quitting from the start menu goes back to the server list, as on a PS2. `eqoa boot ` starts with that server instead of the kept one, and `eqoa play ` (password in `EQOA_PASSWORD`, `EQOA_CHAR=`) goes straight into the world, for scripts. The game's options, the keys, the server and the disc image's place (`disc.opt`, its path alone) are kept in `eqoa-rs` in the config directory (`~/.config/eqoa-rs`, `~/Library/Application Support/eqoa-rs`, `%APPDATA%\eqoa-rs`). Several windows open at once each read their own controller (the first window the first; the Extended page's Controller Focus picks another, the same for several, or the window in front; on Windows a controller that is not an Xbox one, or passed off as one, reaches only the window in front). On Windows `eqoa.exe` opens no console window: started from a terminal, its messages print there. On Linux it needs glibc 2.34 or newer (Ubuntu 22.04, Debian 12, Fedora 35 or later), ALSA (`libasound2`), libudev and a Vulkan or OpenGL driver; the file dialog is the desktop's (its XDG desktop portal, else zenity), and where there is neither the image is named on the command line. The builds are not signed: the first time, macOS refuses one that came through a browser; allow it in System Settings, Privacy & Security ("Open Anyway"), or run `xattr -d com.apple.quarantine eqoa`. Windows' SmartScreen may ask the first time ("More info", "Run anyway"). **A downloaded build updates itself.** As it starts (`eqoa`, `eqoa boot`, `eqoa `), a thread reads the release's `update.txt`, which the release's build writes beside its programs: the commit it was built from, and each platform's program with its SHA-256 and size. When the commit is not this build's, the program is downloaded beside this one, held to that hash and size, and put in its place by two renames (this one aside as `eqoa.old`, which the next start removes); the boot's loading picture says how far the download is, and before the title the window closes and the new build starts in its place, with the same arguments. A build made anywhere but the release's CI carries no channel and never updates itself; `EQOA_NO_UPDATE=1` leaves a release's build as it is for a run, and a failure (no network, a folder it may not write to, a download that is not the one named) is said in the log and the game plays on. While the game runs it asks the release again (three minutes after the start, then every ten; the release, not the game server, so on any server), puts a newer build in place the same way, and asks the player whether to restart now in a window of the game's own that lists what is new in it (the sections of the release's update notes this build's own lack, every version's since if several came out), once nothing else is on the screen, nothing is being typed and the controls have been let go a moment: Restart now leaves the world as Quit does and starts the new build; Later (the choice it starts on) asks again in half an hour. A page asks the same of its player (Reload / Later), with what is new in it. **Problems are reported from the game.** F3, or the HUD's **Report a bug** under the target's bar, opens a dialog over the game (over the screens before the world too): what happened, a name to be credited by on the board (Tab), Send (Enter) or Close (Escape). It sends the note, a picture of the screen, the client's state (the character, the world, the creatures round it, the options, the keys and controllers) and the log's last 200 lines to the report server; its board, [eqoaclassic.com/eqoa/reports/board](https://eqoaclassic.com/eqoa/reports/board), shows the note and the picture and never the rest. The account name and the password are taken out of everything sent, as is the home directory's path. The program's messages are kept in `eqoa.log` in the config directory (the run before as `eqoa.prev.log`; a second window open beside the first keeps `eqoa-2.log`, and so on). When the game stops on a fault it writes a crash report into `crashes/` there, and a run that ended without closing (killed, or stopped where nothing could write one) is found by the next start in the log it left; the next start offers what is waiting (Send, Not now or Delete; the newest five are kept). Nothing is sent unless the player presses Send. ## Method The language is not what makes the feel exact; the method is. Each system is ported from the matching decomp ([eqoa-decomp](https://github.com/DabDavis/eqoa-decomp)) one at a time and proved against a reference: - **Game logic** (walking, camera, collision, animation) uses the EE's float model, crate `ee`: round toward zero, denormals flushed on the way in and out, saturation instead of infinity, conditioned compares, an adder with one guard bit. Every rule but the last was measured in a running game through the tracer (`go-eqoa-vi`); the tracer runs PCSX2's interpreter, whose adder keeps every bit, and the guard bit is PCSX2's recompiler's, which the game plays on. Rust never fuses `a*b+c` on its own, so written order is executed order. - **The reference** is eqoa-port, which runs the game's own functions on the host: a test gives the original and the rewrite the same inputs and compares the bits. Until that harness exists, crates are held to the fixtures the earlier ports were. - **Rendering** need not be bit-exact. The VU1 microcode is fully read, and eqoa-port's GPU strip programs already redo its maths as shaders; meshes are drawn directly with that maths, with no DMA, VIF or GS. ## First slice A test of the feel before anything broad: load a zone and the player's character off the disc, port `VIWalkControl` and `VIWalkCamera`, draw with the strip-program shaders, and compare against eqoa-port frame by frame. If that matches, the rest is breadth: UI, network (DRDP), sound. ## Crates | crate | what | checked against | |---|---|---| | `ee` | the EE FPU: `mul` `add` `sub` `div` `sqrt` `lt` `le` `eq`; and `ee::libm`, newlib as SUPPORT has it compiled: `sinf` `cosf` `powf` (with its `_XOPEN_` wrapper) `atanf` `atan2f` (wrapper too) `acosf` `sqrtf` `fmodf` `scalbnf`. The adder aligns the smaller operand with one guard bit and no sticky bit, as PCSX2's recompiler models the EE (`FPU_ADD_SUB`): bits shifted past the guard are dropped first, and an operand 25 or more exponents below adds only its sign, so `x + -tiny` is `x`. go-eqoa-vi's goldens were measured through the tracer's `call`, which runs PCSX2's interpreter, whose adder keeps every bit (the exact sum toward zero): that one is `ee::interp` (with `interp::libm`, the same newlib over it), kept for the ports checked against it. With it a body creeping on a trace of velocity moved a float step toward the world's origin every walk tick for twenty seconds after a stop (2026-10-20, F3: the slide). Computed natively and branch-free, inlined into the game logic (about half a million calls a frame in play, more than half with a zero operand): the f64 product or sum truncated to 24 bits by a mask, the alignment's masks by selects; `mul4`/`add4` take four lanes at once on SSE2 (`Matrix44::mul`), the alignment's shifts as a float's conversion | goldens from `go-eqoa-vi`; `interp::libm` bit for bit against go-eqoa-vi's ports over a 24,000-input corpus (`tools/vimath-corpus` writes it), except `atan2f(denormal, ±0)`, where the wrapper's `c.eq.s` flushes the denormal (disassembly at 0x0012711C) and the Go does not, and `libm` where the adders part (`cosf(pi)` is -1 exactly); and against its first implementation, kept verbatim in `tests/reference.rs` (the reference for `interp`), and `FPU_ADD_SUB` written out step for step (for `add`/`sub`): `sqrt` and `operand` over all 2^32 patterns, the binary operations and compares over 1,592 chosen values pairwise and random pairs in every regime the shortcuts branch on (`EE_PAIRS=100000000` checked 500M), four lanes against four calls; five planted shortcut bugs and a bound one exponent too loose each fail it, and the alignment's edge one exponent off (scalar and four-lane) or without its partial mask | | `iso` | ISO 9660 walk, read one file | the disc | | `dnas` | The disc's DNAS-encrypted modules (the `.AUT`s: SUPPORT, CLIENT, SETUP, STATION, MCERR) unpacked as `VILauncher::UnpackAndVerify` unpacks them, by the disc's own DNAS library: `UTIL.REL` (on the disc in the clear) loaded and relocated as SN's DLL loader does it, its imports (libc, the kernel's semaphores, libcdvd's S-command RPC) bound to stand-ins, and an interpreter for the EE's integer core running its constructors, its init and the four personalize and extract calls (each word decoded once, the library's self-decrypting code decoded again when it is rewritten); then the inflate. The standard functions the library spends half its instructions in are computed natively (`src/native.rs`): SHA-1's compression, RC6-32/20 and BSAFE's CBC decrypt over it, libgcc's `__muldi3`, `dpmul`, `litodp` and `dptoul`, BSAFE's bignum multiply-accumulate and the stream's buffer reader, each only where its code's CRC-32 is the proved one (else interpreted) and free to decline a call to the interpreter; DNAS's own state machine stays interpreted. No key and no part of the library is in the code. About 110M interpreted instructions a module: 1.2 s natively (5.5 s interpreting all 320M), 1.9-2.7 s in Chromium; the client unpacks CLIENT and SUPPORT on threads of their own (a page's workers) and keeps them (`eqoa/src/modules.rs`) | `tests/discs.rs`: every module with a reference on hand, byte for byte against what the game's own `UnpackAndVerify` returned in PCSX2: the Frontiers disc's SUPPORT and CLIENT through its ISO, and 30 more off six other discs (EQOA's 2002-10-20 beta, USA, Europe, Europe En/Fr/De and its demo, the Frontiers beta of 2003-07-18); one bit flipped in a `.AUT`'s header, body or trailer is refused by the library itself. `tests/natives.rs`: every native call of two whole unpacks (246,000 and 250,000) against the interpreter running the function, the registers a call keeps, its result and every byte either wrote (an undo log), and 200,000 calls of the pure ones on random arguments; a native wrong in its result, its bytes or by a stray write fails it | | `esf` | CSF inflate, the OBJF record tree, DictIDs, surfaces, PrimBuffers, materials, sprites flattened to models; `Library`, the dictionary across files; zones (terrain with pre-translations, actors); a world opened with its zones left on the disc (`Container::open_deferred` through a `ReadAt`: the root and the world walked header by header, every zone a stub naming its byte range; TUNARIA is 20,772 bytes read in 180 reads instead of 997 MB), a zone parsed on its own (`parse_records`), and `Library::add_zone`, which registers a zone's DictIDs at its world's place (ranked by the record's offset), so lookups answer as with the whole file loaded | `go-eqoa-esf-tools-v2`: per-surface CRCs (801 surfaces) and per-model geometry (766 models: counts, bbox bits, position CRC); which records register a DictID, and that the first file loaded wins, from the decomp; the deferred open's world-level records, and each zone parsed alone, against the whole parse record for record (`tests/deferred.rs`; a changed byte and a range one byte late fail) | | `vi` | the game logic, ported from the decomp and generic over a float model: `VIWalkControl`, `VIWalkCamera`, `VICameraEffect`; and **`VIScene::Collide`**: the world and zone BSPs, rooms, collision buffers (all three packings), placed actors and their insertion, Simple/Sub/Group/LOD sprites, `GetEnvFlags` and the liquid-surface ray, loaded from the world file as `VIESFParse` loads it (`world.rs`). Zones come later (`World::add_zone`): a world whose library has only some zones builds those, and each one added inserts the actors already in that reach into it; every room it touched is put back in the whole load's insertion order (zone, room, placement), so a walk meets the same actors in the same order however the zones came. Append only (no `ResetToProxy` yet). Every actor names a sprite its own zone holds (all six worlds, `esf/examples/zone_dups.rs`) | **bit for bit against the decomp compiled for the host** (eqoa-port's `make oracle`), under the `Ieee` model: 18 walk-control and 11 camera methods; `VIMatrix44` Transform/InvertAffine/Mul, `VIPlane`, the transformed `VICollSphere::Init`, the triangle/edge/vertex sweeps, and `CollideObj` over 102 real TUNARIA buffers; the BSP walk against brute force over every room and actor (3,000 sweeps). Negative controls fail; zone by zone (`tests/world_zones.rs`: the zones within 1,050 of Freeport added in a scrambled order) against the same zones at once and against the whole file: room lists, 3,000 floors, sweeps, rays and environment flags bit for bit; a reversed room and a zone left out fail | | `vi` (animation) | `VIHSprite` (the pose evaluator: plays, their timing and 250 ms blends, freezes, LOD rest frames, the hierarchy, bind pose and inverse, centre transform, skin palette), `VIHSpriteAnim` (full and packed clips, triggers), `VICSprite` (66-entry play list, priorities, `SetDefaultEntries`, `SetDefaults`, the `SetLocomotion`/`SetAnimation` arbiter and its two-layer sync), `VIWalkControl::UpdateSprite`, and the loader for CSprite records | **bit for bit against the decomp** (oracle op 500 builds the human from CHAR.ESF as a real VICSprite in a bare VIScene): 320 steps of a locomotion script, all 40 node matrices equal at every step; planted bugs fail | | `vi` (time of day) | `VIScene::InitTimeOfDay`'s 24 hourly lights, sun directions and sky colours; `SetupTimeOfDayLights`/`Fog`/`Sky`'s interpolation; the world's terrain-profile grid, `VIWorld::SetProfiles` and `CalcProfileBlend` | **bit for bit against the decomp** for the table, the 22 profiles and 4,200 blends over TUNARIA's real grid (oracle ops 600, 601); planted bugs fail. The Setup* combinations write the raster and are not compared | | `vi` (atmosphere) | `VIAtmosphere` whole (`vi::atmosphere`): `Init` (the 200 weather particles out of the module's noise, the wind table, the 128 stars), `SetWeather`/`SetDefaultSky`/`SetPlaneOfSky`, `Update` (the sun, corona and moon rotations), `ProcessWeather` (the 10-unit box at the camera, rain and snow by the effect mix, each column dropped to the ground or a liquid surface, splashes), `ProcessTransitions` (the 30-second fade: sun/corona/moon alphas, the dome's third layer, the intensity table, the rain's two voices and the thunder), the lightning (`ResetLightning`, `RecomputeLightningPatches`, `GetLightning`), `RenderStars` (the shooting star too), `RenderWeather`/`RenderSplashes`, the Lavastorm's fireballs on `vi::particle`, `SetSkyColor`; and `VIScene::SetSky`. `vi::tod`: the overcast weight (`GetOvercastInterp` × the profiles'), the Lavastorm weight, the flash on the sun. **The weather types are 1-3 rain and 4-6 snow** at 0.15/0.5/1, read off the code (`vi.h`'s names say rain, snow, sleet, sandstorm, Lavastorm, thunderstorm) | **frame by frame against the decomp** (eqoa-port's `build/oracle_atmos`: `viatmosphere.cpp` with `vimaterial.cpp` and the particle TUs over a recording raster, sound device and scene; `vi/tests/oracle_atmos.rs`): 4,200 frames through night skies, a rain fade, a queued change, snow, an instant change, FX.CSF's own Lavastorm, clearing and both planes of sky, every billboard (785,578), sound call (13,199), sky sprite and model-view, flash (227 frames), material layer, the fade state and all 200 particles equal bit for bit under `Ieee`; a planted difference and a mutated constant fail | | `vi` (effect volumes) | `VIEffectVolume`, `VIBoid` and `VIEffectVolumeSprite::Raster` (`vi::effect`): `GlobalInit`'s cards, the five kinds (falling leaves, light shafts with the sun-following flag, ground fog, dust storm, a flock of boids steered by Reynolds' rules with their locomotion), the hour window with its five-minute ramps, the distance fade, the 60 Hz wall-clock step, the re-seed when the box drifts; recorded as `VIRFan` fans (material, colour, normal, uv and position of all six vertices) and boid sprites with their model-views. A mote born on frame 60 reads `sDustCard[60]`, past the table (the oracle's build has `sFogCard[0]` there); `Set(x, y, z)`'s three draws in `VIBoid::Init` are evaluated last argument first, as the oracle's compiler does. In the client (`fx.rs`), each zone actor placing one (`ParseEffectVolumeSpriteObj`'s record: the box, then the art, then type, hours, distance, strength, density, surface, sprite and flags) gets its own copy as `CreateActor` and `VIEffectVolumeSprite::Copy` make it, when its zone streams in (`World::stream`'s rule) and gone with it; each frame the ones `VIScene::RasterActor` would draw (not hidden, nearer than the sprite's 500, in view) step and render as its deferred pass does (`Process` on the wall clock, `Render` under objectToWorld × view): the fans through `render::fans`, the boids as copies of the flock's CSprite, `SetLocomotion` 1 diving and 0 climbing, posed and skinned as `VIHSprite::Raster` poses them. Tunaria places 7,502 (fog 4,536, leaves 1,576, flocks 729, dust 467, shafts 194; `eqoa volumes ` lists a world's): fog always on or from 18:00 to 07:45, shafts that follow the sun from 05:45 to 20:00, every one seen to 100. `EQOA_FX=0` (a page's `?nofx`) leaves them out | **frame by frame against the decomp** (eqoa-port's `build/oracle_effect`; `vi/tests/oracle_effect.rs`): the cards, then 900 frames of one volume of each kind under a camera circling through their fade distances over a day, a clock that stutters and hitches and a box moved to re-seed: 24,792 fans, 2,727 boid sprites, every scene call and each volume's state with all its particles equal bit for bit under `Ieee`; a planted difference and a mutated card fail. The client's half by `fx.rs`'s tests: the record as the parser reads it, a fan into the world, the camera, `RasterActor`'s tests, and off the disc Neriak's ground fog (F3 20261021-010712, "dont see effect volumes"), its night fog by the hour and its flock flapping and gliding at 30 and 165 Hz, each with a control (six planted bugs each fail one); by eye: the plaza's mist live on go-eqoa-server, a dust storm, a light shaft, falling leaves, the birds | | `vi` (picking) | `VIPickSegment` and `VIRaster`'s pick family (`vi::pick`: both Culls, `Pick`, `PointInTriangle`, `CalcHitColor` with `VIMatrix33`'s Gauss-Jordan, the strip walk, `PickPrimBuffer`'s transform and static light) over prim buffers decoded as the parser leaves them (packed colours `>> 1` then `<< 1`, PackVUNCG with the zone's pre-translations); the world's zone actors carry their static colour (`m_p70`) and static lightings (0x32B0: colour buffers handed out per simple sprite, `GetNextColorBuffer`); `VIScene::Pick` under `VISceneOutdoorsFilter` (world and zone trees, rooms, static actors; group, LOD and simple sprites) and `CalcOutdoorsIntensity` (`vi::world::Outdoors`), which the ambient tracks, the atmosphere, the lightning and the lantern read | `tests/oracle_pick.rs`: 6,000 leaf cases and 4,000 PackVUNC walks (vertex colours and colour buffers) byte for byte, a planted difference fails; `tests/lighting_fit.rs`: 49,979 of 49,982 colour buffers fit their sprite's vertex count (shifted one actor: 47%); `tests/outdoors_probe.rs` on TUNARIA round Freeport: open ground 0.974, floors under cover 0.675. An animated HSprite does not pick | | `vi` (pad) | `VIPad` (`vi::pad`, SUPPORT's): libpad's state machine (a pad not ready torn down, its motors stopped), the actuator handshake (two actuators, a DualShock, `scePadSetActAlign`, answered by `scePadGetReqState` a read later), `Read`'s unpack into a `VIConsolePad` (the four sticks through `ScaleStick`, a dead zone from 88 to 167 of 0..255; each button one inverted bit as a float), `VibrateLf`/`VibrateHf`/`StopVibration` and `Process` (a motor stopped when its time is up, the pad told once a frame when anything changed); over a `Libpad` the platform supplies | **call for call against the decomp** (eqoa-port's `build/oracle`, ops 50-52: the oracle interposes the `scePad*` calls libsupport imports, each answering from a script and logging itself): `ScaleStick` over all 256 bytes; 20,000 single calls from random states and 400 runs of 60 calls (a pad found, the handshake, rumble, unplugged, found again), each compared by its return, the pad's bytes, the `VIConsolePad` and every libpad call with its arguments; every call and branch reached (counted). Planted: L1 and R1 crossed, a handshake call dropped, an expiry a tick late, a dead zone a step wider, a scale an ulp off: each seen | | `vi` (quick chat) | `VITextSet` (`vi::textset`, CLIENT's): the 256-set table by path id (`MakeId`, `ChildId`, `Get`, `Child`, `GetChild`, `Mark`, `Remove`), `VIClient::LoadTextSets` (a record's empty string or lone space leaves its pair as it was, a slot outside 0..3 skipped), and the escapes a choice's title holds (`VIWndMenu4::ParseEscape`, `Expand`: `%W%` the word, the rest dropped, a word near the end cut where `strcpy16` takes the room left in characters as bytes) | **bit for bit against CLIENT's own functions** (oracle op 810: eqoa-port's oracle loads build/libclient.so and runs `VITextSet`, `LoadTextSets`, `ParseEscape` and `Expand` on a script): `MakeId` over [-1, 3]^3, `ChildId` over every id and child (a depth-3 set's past 0 runs off the table: 260 for its second), 4,572 steps over three seeded scripts (trees built and cut, loads with empty strings, lone spaces, NULs, stray slots and repeated sets, the escapes over random strings) and the whole table after each; two planted slips (a lone space kept, a '%' after the letter taken as the end) and a one-character change to the digit test fail | | `drdp` | DRDP, the game's network library, for the client role: the wire buffer and varints, the state channels' XOR-and-RLE deltas, guaranteed messages in 1,300-byte fragments, best effort, acks and flush numbers, RTT and timeouts, instances and resets, routes and the leaky bucket, the CRC trailer with the protocol version. Sends go to an outbox; datagrams come in behind their sender's address | **byte for byte against the decomp** (`build/oracle_drdp`: the decomp's library compiled with only the socket replaced): two instances through a lossy, reordering, duplicating network, 132,000 replies over six seeds; and a real PS2-Sandstorm capture replayed into one client (the 26 KB MemoryDump, 24 state channels); a 1 ms clock fault fails | | `net` | The client above DRDP: the Station login (TCP 9735: the 148-byte request with the password DES-CBC under `des_string_to_key("LPSS-V1.0$@Z>.ADJ")`, the 168-byte reply, the AES-256 UDP token), `VIMemStream`'s zigzag and strings, and `VIClient`'s session: the version hello and 0x0904, the server list on type 252, `ConnectShard` and 0x0001, the character list, 0x002A, the promotion (a type-255 notice), the MemoryDump head that places the player (its DRDP source is the player's id), and `Msg000D`'s world entry in ONE datagram as every PS2 capture's is: 0x0014, `GroupChanged`'s 0x000E (the chat mode kept), 0x00C9/0x00CA (the options' Faction and Loot Messages bits, clear) and the first frame's 0x40 at the dump's place (Sandstorm leaves a player whose first 0x40 comes in a later packet out of its world: its own entity is all it sends, and it never moves it). Then the world: the 0x40 self-state (`SendPosition`, in `vi::selfstate`) every 100 ms, and the entities on channels 0x00-0x17 (`ChannelEntity`'s slot rules, `VIEntity`/`VICreatureEntity::ReadUpdate`, `Extrapolate`'s blend). `eqoa login`, `eqoa play` and `eqoa playshot [ms]` (password in `EQOA_PASSWORD`) | live against go-eqoa-server: login to standing in Freeport in about a second, all 24 entity slots filled and drawn, the player's model as the server names it. Live against Sandstorm (2026-10-21): with the world entry's 0x40 all 24 slots fill at once, without it (the old order) only the player's own; `world_entry_carries_the_first_self_state` holds the datagram to the PS2's order, a dump cut short the control (0x00C9/0x00CA alone). **The 0x40 encoder against the console**: all 413 self-states the PS2 sent in the Sandstorm capture re-encode to its own bytes | | `net` (MemoryDump) | `Msg000D` whole (`net::dump`): `VIPlayer::Read`, the quick-chat sets, quests, the inventory and the bank with its lots and bids (`ReadNetItem`, `VIItem`), a corpse, `VIAbility`s, the speed, the crafting slots, Combat Mastery; the stat channel 0x42 (`ReadStats`) | `tests/dump_capture.rs`: Sandstorm's 26,084-byte dump out of `pcaps/ps2_sandstorm.pcap` (replayed through the Rust DRDP) reads to the 39 zero bytes every retail dump ends with, the crafting slots on retail's markers; cut into its last field it reads short | | the pregame | What the PS2 shows from the disc's boot to the world, in SLUS's scene order (`eqoa/src/pregame/`; `eqoa`, or `eqoa boot [server]`): the launcher's loading pictures (`boot.rs`: DATA3/LOADING0-3.16, raw 640x448 16-bit, `VIDisplayLoadingScreen`'s order, the first by newlib's unseeded `rand()`); SETUP (`setup/`: `VIWndLegal`; `VIWndEula` off the disc's licence, `ComputeTextRows`' quirks kept, declining keeps it up; `VIWndSplash`'s title with `VISplashFx`'s birds and Frontiers' island layer, the `splash_screen` XM, `VIFader`; Options is `VIWndOptions`, SETUP's code; Network Setup is the client's: the server's address, kept); STATION (`station/`: STATION.PAK's files (`VIFlatFileSystem`), its PS2GUIPage XML pages as libxml 2.3.6 accepts them, `VIWndGenericRenderer`'s pages, actions and dialogs over `VIStationAPI` on TCP 9735 (`net::auth`: every request and reply at the decomp's offsets), Create User Name's two pages, the password change, Save Account in `game.opt` (+0x10 the name, +0x50 the password in plain text, +0x90 the flag, as the card kept them), the news by HTTP from `options.cfg`'s NewsURL at the server's host; 59.94 frames a second, as STATION ran); CLIENT (`client/`: `LoadWorld`'s "PreGame Load" under the loading screen's gears; `VIWndServerSelect` (the 50-frame wait, four tries, the home shard kept in `game.opt` at +0x98, the sort with its half-name `memcmp`); `VIWndCharacterSelect` (the slots, Delete with its Yes/No box, the Options box, the preview in the race's home set of ARENA.CSF (`stage.rs`: its twelve dioramas, the race camera's framing, the 8:00 light and sky), the flourish every 5 s, the race's music); `VIWndCharCustFrontiers`; creation's five pages (`client/create/`: race and city, class and the stat points, name and Generate Name, appearance, review); `VIWndDisconnect`); then `Play` takes the session (`host.rs`: the world's files read on a thread, in a page by its reader), and the start menu's Quit or a lost connection comes back as `Msg0777Done` has it (the server list, Station or the title). In a window wider than 4:3 the title's and STATION's pictures (one 640x448 picture in two halves, banded stone, bar, painting, bar, stone) carry on past the frame (`pregame/wide.rs`, the client's own): the stone and the bars mirrored at the frame's edge, the painting's rows the picture's own stone, so the painting stands framed in the wall; the cuts are held to the disc's surfaces. Left out: the controller and language screens, the intro movie, the memory card's checks and boxes, the network interface's configuration, DNAS and the patcher, customer service. | 108 unit tests, each with a negative control, and bugs planted that fail them (SETUP 8, STATION 5, creation 13, character select 4); STATION's renderer on the disc's own pages over a scripted server (the login's bytes and both answers, Save Account, a new account, a password change, the news); the race and class tables token for token against the decomp's `virace.cpp` and `viclass.cpp`; eqoa-port running the real screens as the reference, shots side by side; live on go-eqoa-server, natively and in a page through the relay: from an empty config through the typed login, the news, the server list and character select into the world; accounts made with Create User Name; characters made through creation, listed and deleted; a logout back to the server list and in again. The world's renderer is unchanged by it (`shot` PNGs byte for byte before and after). | | `eqoa play` | On a server: the world streamed around the player, NPCs dressed, armed and animated between updates, name plates (`VINameSprite`'s two phases in the game's Arial), chat (0x0A7A/0x0A7B in, and 0x00CE, the server's error line, in red; `ChatLine` out: `/say`, `/g`, `/gu`, `/s`, `/t`, `/r` on their channels; a command it does not know goes to the server as typed, the client's own as Sandstorm's S2 forwards them (the game answers word 826 itself), so a server's commands take a slash: go-eqoa-server's GM `/goto`, `/summon`, `/spawn`; the profanity filter's state, 0x0045, as `Msg0045` shows it), targeting (`CycleTarget`, 0x0010; `TrackTarget`'s selection rings, UI.CSF's "Selection_Rings" drawn over the scene with the Z test off at the target's slot 4, eased to its con colour (0x0010's second byte), `rings.rs` over `render::overlay`), attacking (`ToggleAutoAttack` held, 0x000F and the 0x40's byte). Talking (`InteractWithTarget` on Q by the npc's flags; the reply 0x0046 through `VIClientText::Expand` into `VIWndPrivChat`'s box, queued as `m_dialogs` is; Enter answers it). Teleports in the world (0x00B2: `Msg00B2`/`Respawn`, answered with 0x0049, landing on the floor `Respawn`'s cast finds from 3 over the server's place to 3 under it, not a roof over it; coach travel and GM `#tele` both arrive this way; another world's file, by `gWorldInfo`'s six disc paths, replaces the world and the walk begins again there, on the floor the same casts find: Freeport to Odus by `#goto`). The world entry stands the player where the dump puts it (`Msg000D`'s `SetLocation` at its place, no floor looked for), so a login in a house or a cave comes back inside it; until 2026-10-23 every arrival stood on the topmost floor there, the roof or the land over the cave. Every move and world entry is covered by the loading screen (`VIClient::LoadingProgress`, `loading.rs`), as `EnterWorld` covers its `StreamFillCache`: the logo and the bar out of UI.CSF's "sheet1" and the atlas (the Frontiers layout; the bar is art, the game measures no progress) and UI.CSF's "Loading_Gears" turning in the corner through the camera's projection (kept at the right side in a wider window), over black, up until the zones round the arrival are in and drawn (`stream::Streamer::settled`: within 900, measured flat for the read and whole for the GPU, every zone uploaded at once meanwhile), the walker held there and put down on them after; 60 s at most. Natively a load is a frame or two, in a page as long as its reads (F3 2026-10-21: a coach ride's arrival was a blue void until its zones came). A dump in play (`Msg000D`: a respawn is one, at the bind point) puts the player at its place as the world entry does, then ends a death; a warp does not. The player's own body is hidden as `UpdateGame` hides it: in first person, in the follow camera when the camera is inside the head (`m_p00088`), and under npc flag 0x200; an invisible creature's (`ApplyLook`'s `Flag1`, state byte 154's bit 1), the player's too, has its skins hidden (`SetSkinsVisible`), what it holds, its helm and its name kept. The run speed is the server's as `UpdateGame` takes it: the dump's `m_speed`, then 0x00F8's and Class Mastery's 5 and 6 through `SetSpeed` (its walk speed 3, or 0 with a run speed of 0), 1.05 times it while following (Sandstorm: 8.833333, 11.483333 under Spirit of the Wolf); until 2026-10-23 the walk kept its own 8.83 and no speed buff moved it. Server-driven actions (0x0775) and the level and experience (0x0020). Groups: `InviteEntity` (0x0625), the invitation's prompt (0x0773), `AcceptInvite`/`DeclineInvite`, leave/remove/disband, the roster (0x0772) and the members' health and place (state channel 0x40). Summons and resurrections: 0x00D3's Yes/No box (`Msg00D3`: the caster's name, then word 610 or 611, run together as the console runs them, put up over whatever window was open), answered with 0x00D4 (`SendD4On`/`SendD4Off`). Abilities: the dump's list kept by 0x00B3/B4/B5/B6/B8/CD with `Msg00B4`..`Msg00CD`'s rules, used from the HUD's menus as `Send0032` (token, slot, target). Keys are the game's own keyboard (`VIWndGame::OnKeyDown`/`OnKeyUp`, `VIWndHUDMenu::OnKeyDown`, the codes `VIKeyboard::Read` gives) where they do not collide with walking on WASD, the user's choice (2026-10-20): WASD walks (the client's), the arrows are the quick menus' d-pad (up and down the selection, left and right the menu) and E uses it (the game has W/S/A/D on the quick menus and the arrows walking), Home and End orbit the follow camera, Page Up and Page Down pitch or zoom (`UpdateControls`' `m_keys[21..=24]`, `controls::Client::keys`); Space attacks, Tab targets the next creature, ` or ~ cycles the player, the group and the pet (L1's `CycleTarget(1)`), 1 to 6 pick the player, the group's others and the pet (`SelectTarget`), Q talks, X toggles autorun, C held scrolls the chat box's history (as R2), Enter (as R3) or `/` opens the say box, the edit box R3 opens (`/` begun with "/"), R and T with `/reply` and `/tell` (`Play::chat_key`; a plain line before the HUD is up); Z opens the quick-chat menus as L2 does (below: the arrows are their directions there, held to edit, Enter the chat line and in the editor X, as E is, Escape, Backspace or Z back out). The client's own beside them: I/J/K/L a right stick, F5-F8 the group's actions, F11 full screen, F1 the options' Controls page (the row "the options": every key here can be moved to another there, and its last lines say which key is which button in a window: ✕ Enter or E, △ Escape or Backspace, □ Q, so a trade's Confirm Trade is Q). Without the HUD (no TEXTURES.TXT) the chat log and the target's name are drawn plainly | live against go-eqoa-server; `eqoa playshot` with `EQOA_CHAR`, `EQOA_SAY`, `EQOA_TAB`, `EQOA_TARGET`, `EQOA_ATTACK`, `EQOA_DIE` (0x005E then, client side) drives it headless, `EQOA_LOADING_HOLD=ms` (in a page `?loadinghold=ms`) holds the loading screen up that long for a shot; `own_actor_shown`'s arms by unit test with a control; the loading screen by `loading.rs`'s tests (the 2D half blit for blit and its original-game control, the corner's place at 4:3 and 16:9, the wait, the gears off the disc turning inside the corner) and `stream`'s `settled_round` (a planted whole-only measure fails), live natively (a far `#goto` across Tunaria, Lavastorm and back) and in a page at world entry, WebGPU and WebGL2 | | `vi` (characters) | `VICSpriteCust`'s tables and `ApplyLook` (`SetRobe`, `SetArmorSlot`, `SetHair`, `SetFace`) over a CSprite's 19 text slots; items and the helm attached at their nodes (`AttachItem`, `SetHelm`), items only on a sprite whose header says it holds them (`Init`'s `npc`: a creature a form or an illusion makes of a player holds none, its weapons still on its channel); creatures between updates (`ExtrapolateMove`/`ExtrapolateGround`, and a player's own `VIWalkControl` via `SetUpdatePhysics`) | by eye on a live server: Freeport's NPCs in their armour, helms, faces, hair and weapons. Which sprites hold items, on the disc (`vi/tests/csprite_items.rs`): every race's model, neither creature Sandstorm turned a bard into (`pcaps/`). The tables are the decomp's constants; the oracle has no raster to apply them through yet | | `vi` (combat, words) | `GetSwing`, `SwingEffect`, `SwingDelay`, `AttackAnim` (`vi::swing`), `VIHashResourceID`, `VICSprite::SetAction`, `VIStringTable` (WORDS.TXT); in `eqoa play`, `AutoAttackTick` with `TargetInRange`, `CheckPower`, `HasAmmoFor`; after a weapon swap in a fight (the swing a due tick makes changed) the swing timer follows the server's rounds, the client's own (`attackclock.rs`: each round's first own hit puts the next swing where a fresh press would, one server round on; no swap, the game's timer) | **the swing tables equal the decomp over their whole domains** (175,180 entries; oracle op 800 loads CLIENT); `SetAction` in the animation oracle's script; the string table's rules by unit test. The swing's spell effect is not drawn (`VISpellEffects` not ported) | | `vi` (spell effects) | `VISpellEffectSystem` (24 playbacks, handles, the minute's backstop), `VISpellEffectDriver`, `VISpellEventDriver` with all 17 event types (emitters placed at an actor's node or orbiting it, motif blends, attractors, actions and attacks, waits on an action, the clock, the cast time or an animation's "Spell FX" trigger, projectiles in their four flights, lightning at the target, sounds and sprites made and moved, camera shakes, rumble), `VILightning` (`vi::lightning`), the 0xC200 record; in `eqoa play`, `CastingCombat` (0x00B1), `Msg0776` and the player's swing (a swing's attack event plays the hand as the creature's sprite holds it: an empty hand 11, hand to hand, as `VICSprite` starts and detaches, and every hand of a sprite that holds no items; a held item's type off the entity channel, the player's off what the dump says is worn), drawn (particles, lightning strips) and the sound sprites they make heard (`UpdateSoundEmitters`' second half). `EQOA_SPELL=n` with `eqoa shot ... walk` casts SPELLFX's n-th effect offline | **call for call against the decomp**: eqoa-port's `build/oracle_spell` (the spell, particle and lightning TUs over a fake world) and the same fake here (`tests/oracle_spell.rs`): all 238 SPELLFX effects, 16,000 frames, 46,895 world calls, 511,901 lightning strip records and 3,512,650 billboards equal bit for bit; a planted record, a longer wait and a changed world rule fail. In the client by eye (`EQOA_SPELL`). Not ported: the weapon trail, a hand's projectile sprite, the animated sprites an effect makes (tracked, not drawn), the fade (unused on the disc), rumble; the effects have a particle system of their own | | the pad | The game's own controller, `VIConsole`'s pad and the client's use of it (`input.rs`, `controls.rs`): libpad on the host, port 0 every source OR'd as eqoa-port's `platform/pad.c` ORs them (the gamepads natively through gilrs (SDL's layout) and in a page straight off the Gamepad API (`padmap`: the spec's standard layout; Firefox on Linux's own, which reads evdev and takes its codes at their word, so a pad xpad drives (a name with X-Box or Xbox, or a vendor xpad takes by interface) comes with X and Y swapped and its triggers on axes 4 and 5 (Firefox gives an axis as 0 until it first moves: xpad's count once moved, any other pair once both have read -1 together, as a generic HID pad's right stick sits there); no mapping positionally, or in xpad's joydev order when axes 2 and 5 rest at -1; `?padxy=swap|keep` overrides), all positional, A ✕, B ○, X □, Y △, Back Select; the triggers past a third as L2/R2; a script, `EQOA_PAD`), each stick axis scaled by 1.33 and clamped as PCSX2's DualShock 2 is by default (a modern stick runs in a circle, a DualShock 2's nearly fills its square: a full diagonal runs). Before that a gamepad's stick goes through the host's radial dead zone (`input::STICK_DEAD_ZONE`, 0.3 of the stick's travel natively and 0.37 in a page, the edge a page had through gilrs; `?deadzone=` in a page, `EQOA_DEADZONE` natively; a script and the touch pad are exact): `ScaleStick`'s dead zone ends at 0.236 after the 1.33, and a modern stick may rest 0.24 to 0.265 off its middle (Microsoft's figures), a worn one further: one resting past the edge walked the player sideways or turned it slowly with nothing held (F3, a PowerA pad in Chrome; until 2026-10-20 a page read the pads through gilrs, whose own radial 0.1 put this edge at 0.37 of the travel there). `VIPad::Read` once a frame (a pad lost reads as all zeros, as `VIConsole::GetInput` clears it), then `VIClient::PadKeys`: each field whose zero-ness changed is key `128 + i` to `SetKey`, which hands it to the window that has the keys: the edit boxes (the soft keyboard), the NPC, bank, support and mail windows, the start menu and every window it opens, the dialogs; in play `VIWndGame::OnKeyDown`/`OnKeyUp` (Start the start menu, Select the camera style, ✕ attack held, □ talk or the target's details held, △ let the target go (`ClearTarget`), R1 the next creature in view, L1 the player, the group and the pet in turn (`CycleTarget(1)`, each cycle starting from its first when changed to), the d-pad and ○ the HUD's quick menus, R3 the chat line in the edit box, L3 autorun (`ToggleP74`, "Autorun: On"), R2 held the chat box's history (`VIWndChat`'s scroll, mode 33: up and down, repeating), L2 the quick-chat menus (the row "the quick chat")). `UpdateControls` (`controls.rs`) on the sticks in every camera mode: first person and the follow camera move, turn on the right stick and pitch; mode 1 turns on the left stick and orbits on the right; the orbit camera moves relative to the view, orbits and zooms on the right stick (swims under liquid); the options' Camera Pitch Axis; facing the target while fighting and for a second after a swing; autorun until the stick passes 0.7; the player's own cast walks (`CastingCombat`'s window); the start menu's framing locks movement and the camera; follow (`ToggleFollow`, the quick command or `/follow`, on a targeted member of the group: turned to, the camera orbiting round in the follow orbit, run at, walked to within 6 and stopped at 3, turned to first past 60 degrees, pitched to it under liquid; ended by the attack, the stick past 0.7, the member gone, a death or a teleport; 1.05 times the run speed meanwhile, the speed being the walk's own, as the client applies none of the server's); 0x00FC roots (`IsTimedOut`, 8 s with nothing arriving, is ported but not applied: go-eqoa-server sends nothing while nothing changes, which would stop a player standing still; Sandstorm's capture is never silent for more than 2.6 s). The keyboard is a pad's sticks too (WASD the left, I/J/K/L the right; with the mouse's right button held A and D sidestep in the follow orbit too, the row below), and the game's own keyboard axes (`Client::keys`, `m_keys[21..=24]`) take Home and End (the follow orbit's turn) and Page Up and Page Down (pitch, the orbit's zoom); its arrows are the d-pad here, the quick menus' keys, because WASD walks (the game's keyboard has W/S/A/D on the quick menus and the arrows walking); the keys held are let go when the window loses the keys (another window), as the mouse's buttons are. A pad lost in play says so (word 1805) and stops the attack and autorun. The edit boxes' face is the game's one `sPadOnly`, set each frame: a keyboard in use with the options' Soft Keyboard on shows the compact legend, a pad or a touch screen the soft keyboard. Rumble: the spell effects' vibrate events (`Rumble`: the small motor, or the large one at a level, for its time, with the options' Vibration on) through `VIPad`'s motors to gilrs's force feedback natively and the Gamepad API's dual-rumble in a page (`window.eqoaRumble`). `EQOA_PAD_TRACE=1` prints each key as the game gets it; `EQOA_GAMEPAD=0` reads no controller. Which controllers a window reads is its Controller Focus (the Extended page, `input::pick`): Auto alone reads every one, as the client always did (on Windows while it is in front, as Windows.Gaming.Input gave them); with other windows of the client open on the computer (multiboxing, `multibox.rs`: each takes a place, the first free, a lock file in the config directory's `windows/` held while it runs or a Web Lock in a page, and says beside it the controller it reads) one each, worked out alike in every window: a Pad's first, then each Auto window keeps its own (and waits 15 s for it when it goes, so a controller waking comes back to its window and no other moves), then the first window the first controller, the second the next, or the first nobody reads; read in front or not. Window reads every one while the window is in front, Pad 1 to 4 that one (several windows on the same Pad are all driven by it at once: one controller for several windows), Off none. The controllers' order: on Linux by device node (the order the system made them in), on Windows XInput's players first (the Xbox controllers and those that pass for one, read through XInput, which answers a window behind another, as Windows.Gaming.Input does not; every other controller is gilrs's and reaches only the window in front), elsewhere gilrs's slot, in a page the Gamepad API's index. The motors are driven only on the controllers the window reads. The player's own buttons (the options' Controls page, `binds::PadEdge`, the client's): the controllers' buttons (gilrs's natively, the Gamepad API's through `padmap` in a page, and `EQOA_PAD`'s, which stands for a controller) a permutation onto the game's 16 fields before `VIPad::Read`, so ✕ and ○ swapped is the physical ○ arriving as ✕ (134) everywhere the game reads it; the touch screen's pad, which draws the game's glyphs, is left as it is, and the sticks stay sticks. While the page waits for a button the one pressed is its, and the game sees nothing of it until it is let go; the log says what was moved when the keys are read (`keys and buttons: ...`) | `vi::pad` against the decomp (above); `UpdateControls`' calls per mode, the option, the locks, autorun, the facing and follow against the C by unit test (a recording walk), each with its counter-case; the host's dead zone by unit test (a stick resting at 0.24, 0.265 or -0.29 is a push to the game without it, rest with it; past it a push is unchanged) and in a page (`drive.py --gamepad`, a pad resting at 0.34: `?deadzone=0` walks the player round, the default leaves it still); three walks `shot` byte for byte as before (forward, into the river, an orbiting strafe). Live on go-eqoa-server through `EQOA_PAD` in a playshot: Start opens the start menu (its ✕/△ hints), the d-pad and ✕ the inventory, R3 the chat line with the soft keyboard and its pad legend, the left stick walks, L3 autoruns until the stick pulls back. In a page through `drive.py --gamepad` (a scripted pad behind `navigator.getGamepads`, read by gilrs): Start, the d-pad and A to the inventory, the stick walking into the sea; read by `padmap` since (by its fields: the stand-in is a plain object): button 2 □ (no swap there), Start the start menu and △ out of it, the stick running and let go stopping. `padmap` by unit test (Firefox's evdev layout with xpad's pad and with a DualSense, the standard layout, no mapping, Firefox's 0 before an axis moves, the tester's pad at rest through `VIPad`; each with its control, ten planted bugs each failing one) and live in Firefox ESR 140 (headless) with a uinput clone of the tester's PowerA pad (20d6:2005, xpad's ranges, its rest read off it): shown after a button, X □ and Y △, L2 and R2 from their axes' first press and not at 0.41 of the travel, 30 s at rest with the body, the camera and the position unchanged, the right stick orbiting and let go to rest. The keys let go on a blur in headless Chromium (Firefox fires no blur headless): w pressed, the canvas blurred, w let go on the body, the keys held empty and the run stopped. A DualSense found by gilrs natively (with rumble), idle: no key. Controller Focus and multiboxing: `input::pick` by unit test (alone, in order, one pad for two windows, a lost pad waited for, a spare passed over, a chosen Pad first, a shared Pad, each with its control), `multibox`'s places over real lock files, each window's setting kept beside the others'; live natively with uinput Xbox 360 pads (python-evdev) and two or three windows sharing one config directory (`EQOA_PAD_TRACE`: each press to its own window only; a pad unplugged and back, gone past 15 s, a chosen Pad 3, two windows on Pad 2, Window with the X focus moved); in a page, two windows of a headless Chromium seeing the same two stand-in pads (each its own, then the one left every pad again); on Windows 11 (GitHub's `windows-11-arm`, ViGEmBus pads: two Xbox 360, a DualShock 4) the same, the window behind reading its XInput pad, the DualShock only in front, nothing alone behind another program, two windows on Pad 1 both driven, Window following the clicked window. The bindings by unit test (nothing moved is every button its own field over all 65,536 sets, a swap and a three-way keep it a permutation, a press while awaited kept from the game until let go and pressed again, each with its planted bug) and live (the row "the options": `EQOA_PAD` natively, `drive.py --gamepad` in a page). Not here: holding L1 or R1 to look (mode 38, `BeginManualCamera`: the camera effect's blend into a scripted camera is not ported) | | the mouse and the touch screen | The PS2 reads neither; these are the client's, laid over the game's inputs. In play, the mouse (`mouse.rs`) is World of Warcraft's (the user's choice, F3 2026-10-21): a held button that drags takes the pointer (a page's Pointer Lock, asked for at the drag, within the press's activation, so a click never hides the cursor, and in the press itself once a browser has refused it twice running, `Mouse::lock_seen`; natively winit's `Locked`), the cursor hidden and the look on the mouse's own motion (`DeviceEvent::MouseMotion`: a page's `movementX`, natively the device's counts), so no screen edge stops it; where there is no lock (X11, Windows) the pointer is `Confined` to the window, the cursor's moves are the look and it is put back to the window's middle as it nears an edge (`Mouse::recentre`: under X11's grab every raw motion comes twice); let go, the cursor is back where the press was; a window or the start menu opening, a lost focus, Escape (which a browser keeps to end its lock) and a death let go of it, clicking nothing. The left button looks round the orbiting cameras without turning the body (across the orbit angle, the view turning the way the mouse goes; up and down the tilt, from 45 degrees under the head to 75 overhead (`mouse::tilt`: in mode 3 the default pitch its own snaps go back to, so near a wall it still takes the game's alternate pitch); low, the ground pulls the camera in as the game's sweep pulls it in from anything, to its closest, and it looks up past the body at the sky). The right button steers: in the follow orbit the body turns to the view as the hold begins, then the body and the camera behind it turn together, and the walk is on the body's own axes (`controls::Client::steer`, the client's: W and S forward and back, A and D a sidestep at the game's walk speed with its strafe animation, as first person walks; the camera is put back behind every frame, since the game re-derives the orbit from where the camera was, and where the game would swing it round to a clear angle it comes in to its closest; the mouse's heading wins over the fight's turn to the target); both buttons run forward, steered, A and D adding a sidestep (the diagonal). In first person and the follow camera either button turns the body at once (`mouse::set_heading`: full circles) and pitches it within the walk's range (in first person 89.4 degrees up and down, the game's `PITCH_LIMIT`; at a liquid's surface the pitch is the stick's dive). The Camera Pitch Axis option turns the pitch round (in the orbits the orbit too), as it does the stick's; with no button held the keys are as they were. The wheel zooms the follow orbit, in from where the camera is and out from the distance chosen, past the closest into first person (as P's style 0) and out of it back to the camera it left, at its distance and tilt (`mouse::Zoom`, `camstyle::zoom_in`/`zoom_out`, `Play::wheel_style`); the follow orbit zooms out to 11, the client's, where the game stops at 8 (`camstyle::FOLLOW_MAX_DISTANCE`). A wall pulls the follow orbit in as the game's sweep does (`MoveCamera`'s flag 4 to 3.5, then its closest) and the game keeps that distance; here, once the distance chosen is clear again, the camera eases back out to it (`camreach`, the client's: the distance the wheel, the stick's zoom or a style chose; from rest to at most 12 a second, 3.5 to 11 in about 1.3 s; room for only part of the way leaves it in, or a body backing toward a wall would be pulled in again and again). A click targets the creature under it (`Play::creature_at`: of the creatures whose upright from the feet to the name plate passes within a quarter of its screen height, the nearest), a double or right click talks to it (`InteractWithTarget`) or opens its corpse; on the HUD a click uses a quick menu's row (its title bar the next menu), opens the chat line from the chat box (as R3), and targets the player, a group member or the pet from their frames (as L1's cycle). On the windows (`pointer.rs`, `Play::point`) a pointer is the keys each window takes. Every button glyph drawn is that button: the HUD's raster notes where each of the atlas's button glyphs went, with the words drawn after it (`EQOA_GLYPHS=1` prints them), so every legend ("✕ Select △ Exit", "R2 End", "(R1)") is clickable; the side arrow (atlas 128) is not one, it points in as often as out. A window notes where it drew its rows, tabs and fields (`pointer::Hits`): a click selects a row (the move's sound) and a click on the selection uses it, as ✕; a popup's row, an option list's, a message's choice and a box's button are used at once, and a click off a popup closes it (a box and an editor keep it: what was typed is not lost); tabs, pages and the other of two lists step there with the keys that switch them; a value changed with left and right (the options' sliders, the trainer's points, Class Mastery's pages) takes a click's side of it; a digit picker puts its cursor on the digit, and above or below it is up or down; where a window's own move does more than move (the trainer's item list, the options' page rows) the click presses up or down to get there. A right click is △, the wheel (a notch a step) and a finger's drag are up and down. A pointer's keys go through a queue, one a frame, each held until the HUD's next frame has drawn: the windows' `KeyFlash` acts only on a key still down when it draws. Every window has it: the start menu and its sub-menus, the inventory (tabs, rows and their popups, the quick-slot column, the quantity picker, the destroy box), the spell book (abilities, level tabs, slots, popup), the who list, the buddy and ignore lists, the guild roster, the options (the Controls page's rows and cells, a click on the selection waiting for its key, any click giving up), the quest journal, the trainer, Class Mastery, the factions, the tradeskill windows, the corpse, the merchant, trading (its three lists, the money and quantity pickers), the bank and the auction (sell page, search form, results, bids), mail (compose, inbox, reader), tells, the blacksmith, the customer-service forms, dialogs, boxes, the edit box's soft keyboard. The touch screen (`touch.rs`): an on-screen DualShock 2 in the game's own glyphs (SUPPORT's atlas: the face buttons, L1-R2, Start, Select, L3, R3, the d-pad's arrows), one more source on the pad's port, so it does all a pad does: a floating left stick in the lower left, the face buttons at the lower right, the d-pad above the stick, the rest along the top (two rows in a narrow window); while a window is up only the face buttons stay (△ backs out of every window) and a finger anywhere else is the window's, a tap as a click (missing by up to six frame pixels) and a drag scrolling it; over the quick-chat menus the d-pad stays too (a direction is held there to edit its choice; a tap on a choice is that direction tapped), and a mouse press on a choice holds its direction until it is let go (a click is a tap, half a second the editor); a finger dragged elsewhere in play looks as the mouse does, two pinch the orbit's distance, a tap targets and a double tap talks. Shown from the first touch, or from the start on a touch-first page (`(pointer: coarse)`); a key or a controller hides it. A mouse says a keyboard is at hand (the edit box's compact face, with the options' Soft Keyboard on), a finger that none is. `EQOA_MOUSE` (`click`, `rclick`, `wheel`, `drag`, `press`/`release`, `move`), `EQOA_TOUCH=1`, `EQOA_TOUCHES` and `EQOA_TAP` script them in a playshot; `drive.py --mouse/--touch` in a page | unit tests: clicks, double clicks, right clicks and drags; a drag taking the pointer (4,000 pixels of the mouse's own motion looked with, a page's two events for a move counted once, a confined cursor's moves not; a still press takes nothing and clicks), no motion of its own, a refused grab, a confined cursor (its moves the look, not the raw motion, put back to the middle once, that move no look and a move folded into it counted), a page refused its lock in two drags' moves asking in the press (its own motion finding the drag, a click still a click; a lock granted between, or one refusal, changing nothing), a window, Escape and a page's lock lost or granted late letting go, one of two buttons let go; the right button turning the body to the view and then the body and the camera together (the left alone as the control), a steered angle snap, first person's full turns, its pitch stopping at 89.4 degrees, the option, the surface's dive (the touch screen's look as the control), the wheel; on a floor (a test room) a sidestep in a line with the strafe animation and both buttons' diagonal, against the game's turn to its way and the camera dragged round unless put back; `UpdateControls`' steer; the way back out after a pull (from rest, at most 12 a second, the same time at 30 and 165 Hz, nothing for part of the way, the choice from the stick's zoom and a style but not a preset), each failing with its planted bug; the layout in four window shapes (every button on screen, none overlapping, all sixteen there); fingers held, the stick's push, looks, pinches, taps and double taps, and what a window up leaves to the pad; a glyph and its label, hits on top, steps; for every window a pointer test drawn through a null raster (a row selected then used, popups, tabs, pickers, boxes, the unfocused child), each failing with its hit notes taken out. Live (playshots on go-eqoa-server, `EQOA_MOUSE`'s `move`): the right button and 500 pixels turned the body and the camera 4 radians together, the camera behind; A, a sidestep at 3.1 m/s along the body's left (loco 10), the heading kept; both and A, the diagonal at 9.1 m/s, 45 degrees off the heading; first person 1.54 turns in three moves, then straight up; the left button's tilt to 45 degrees under pulled the camera from 9.69 to 1.5 off the ground, looking up at the sky past the body, and tilted back it eased out to 9.69 in 1.3 s. In a page (headless Chromium, WebGPU, `drive.py --mouse`): a right press and 20 pixels took the Pointer Lock, 240 more turned the body and the camera 1.92 radians with the cursor held, and let go the lock went; Escape let go without opening the start menu, the release after it no click; both buttons and A ran the diagonal (9.1 m/s, 45 degrees off the heading); a page made to grant the lock only inside a press (`--init` refusing it elsewhere, as Firefox may) turned two drags with the cursor and asked the third's in the press, held. Firefox itself is not checked: headless, and headed on an Xvfb with no window manager, it refuses every lock. Natively on an X server (Xvfb, xdotool): the right button and 1,010 pixels turned 8.08 radians (16 before the confined pointer took the cursor's moves), the cursor kept in the middle, and let go it was back at the press. A drag of 150 pixels turns the view right (the character seen side on, facing right); a click on Archivist Vaal where he is drawn targets him (the server's 0x0010 reply fills the bar), a right click asks to talk (the server's log: too far, 11.3 > 10); a tap on the Mercenary targets him; the touch stick walks; a click on the player's frame targets the player. On the windows, live on go-eqoa-server: the wheel moved the start menu's selection two bars and a click on its "✕ Select" opened the inventory, a right click went back; the Equipment tab clicked, a row clicked (its stats beside it), clicked again (its popup); Community, Mail and Compose by clicks, then Subject twice (its editor); the who list's row twice (its Tell/Invite/Mail popup); the options' Sound page, and a click left of Fx Volume's knob turned it down a step; the merchant's Buy; the spell book's ability twice (selected, its popup); a quest twice (the delete box); the trainer's Agility; earlier, the soft keyboard's h, i and End sent "hi", a dialog's second option answered 1. At a phone's 2400x1080, a finger's drag moved the start menu two bars and a tap on its legend opened the inventory | | the HUD | `VIUI` over a 2D layer (`render::ui`: quads in the 640x448 frame, the GS's blend modes, the frame kept 4:3; in a wider window each piece of the HUD keeps to its own side (`Anchor`: the frame, gauges, group, effects and target's bar left, the chat box, quick menus, breath gauge and target's details right; the strip a fixed chat box lays under itself and the target, the backdrop's lower part, the client leaves out with it, `ui::chat_strip`, F3 20261023-080729) and a full-screen menu takes back under it what it covers on a 4:3 screen, where a box (the say box, a dialog, the quick chat, the options) leaves the HUD at the sides (F3 2026-10-21: the say box took the compass and the gauges back under it); a piece with the frame the frame's whole width runs the window's whole width (the backdrop every full-screen window but the start menu puts under itself, atlas entry 32 over the frame's top 332 lines, the client leaves out: the world shows round those windows, the user's choice, `ui::window_backdrop`) (the whole screen set back as the viewport is none, `render::ui::viewport_of`: a long name scrolled in the target's bar or a quick menu had left every backdrop after it at the frame's sides, F3 2026-10-21; every window the start menu opens checked at 16:9) (`Anchor::Wide` is a piece laid out wider than the frame, cut to the window's sides; `Anchor::Frame` one kept in the frame and cut to it, never run across the window, that takes no piece of the HUD back under it); a blit bigger than its surface repeats it, as the blit material's CLAMP_1 0 makes the GS do: the menus' panels and frame edges; every quad is drawn 1/32 of a frame pixel up and left (`render::ui::SAMPLE_BIAS`), so that a target pixel whose centre falls on a texel's edge takes the texel its frame pixel's centre would, as the GS does with `VIRaster::Blit`'s half-pixel corners: at 1.5x the title's sail, mirrored, took its atlas cell's black frame at its stern every other step; nothing moves at 640x448, `render/tests/ui_sampling.rs`): the atlas (`GetUITexture`, SUPPORT's table: `read_atlas`), `Blit`/`BlitEx`/`BlitHorz`/`BlitVert`/`BlitRect`/`BlitBar`/`BlitFrame`/`BlitFillFrame`, "ArialUI" (`DrawString`, `DrawCenteredString`, '?' for a missing glyph), `VIUIWindow` (`Draw`, `TextBox`, `DrawTextInternal` with its inline icons), `VIUITextScroll`; `VIWndGame::OnDrawSelf` (frame, compass and the group's patches on it (`DrawPatch`: each other member within 2000 at its bearing, in its index's colour, ADDART.CSF's art; the target's blinks), health/power/experience gauges, the target's bar with its scrolling name, health and disposition from 0x0010, the disposition's face gone while the target lies dead: `Process` drops the info at its +0x19's 0), `VIWndChat` + `VIChatText` (the box that slides up, 20-second lines, the game's wrap; the history R2 scrolls holds the last 1,000 lines, the client's, where the game's ring of 32 holds 31) | **`vi::ui` (VIUI, VIUIWindow, VIUITextScroll) records the decomp's raster calls exactly**: `tests/oracle_ui.rs` drives 4,733 calls (every atlas id through every blitter, tiling, bars, frames, words, window animation, `TextBox`'s flags, inline icons at a fixed clock, 700 frames of both scroll styles) through eqoa-port's `oracle_ui` (the decomp's UI TUs over a recording raster) and here; 11,980 raster calls equal; planted bugs fail; the atlas is SUPPORT's on both sides (here `run/support.bin`, else the disc's unpacked by `dnas`), and `tests/atlas.rs` checks it reads whole (252 entries numbering themselves, mode 0, alpha 1, rows and rectangles in range) and that a table off by one entry, a misnumbered entry or a short image is refused. `net/tests/effects.rs`: `Channel43`'s rules (the 132-byte stride against go-eqoa-server's 66, the 31-character name, the count past 8, nothing read after an error), each with a control; unit tests: the damage numbers' list, bounce slots, glyph grid, delta, cull and fade, the patches' bearing. The HUD and chat box (CLIENT) by eye on a live server: a two-account group's patch; effects and damage injected in a playshot (`EQOA_EFFECTS`, `EQOA_DAMAGE`), as go-eqoa-server fills no effects and swings only in reach. Also drawn: the active effects' icons (`VIWndInfluence` at (143, 15), the bar `UpdateMode` shows in play and over the boxes that leave the HUD up; state channel 0x43 read as `Channel43` reads it, 132-byte records, at most eight; their names scroll in the start menu's Status line), the floating damage numbers (`VIClientPart4FD28`: 0x00DB's `DamageFromSelf` over the defender and `DamageFromOther` over the player, 32 numbers, three bounce heights a side, FX.CSF's damage font growing, fading and shaking over slot 4 for a second, off under the options' Damage Numbers; projected through the drawn view into the frame, so not cut at its sides), the pet's bar (`DrawPet`, 0x007E/0x007F), the breath gauge under liquid (`DrawStatus`), the target's details box (`DrawTargetInfo`, Q held on a player), and the three quick menus (`VIWndHUDMenu`: `DrawList` with the abilities' icons and lockout badge, `DrawStack`; `UpdateQuickBars`' `BuildAbilityMenu0/1` and `BuildQuickMenu`; the arrows are the d-pad, E is X, the camera moves to I/J/K/L). The start menu (Escape in play, the character framed by `ApplyRaceCamera` from CLIENT's `gRaceCameras`: `VIWndStartMenu`'s frame (its band, rails and side panels run off a TV's sides; in a window wider than 4:3 it stays the game's 4:3 picture in the middle, band and all (`Anchor::Frame`), the HUD at the window's sides beside it, compass and all (the user's, 2026-10-23: from 2026-10-20 its panels went out to the window's sides), and a post closes each of its edges, the client's: the middle frame's studded side from the screen's top down to the bottom rail's corner, `startmenu::close_sides`; the inventory over it stays in the picture too, the post drawn again over its own left panel; and so does the spell book, whose side panels run off a TV's sides too, its top the start menu's band (the game's own backdrop shows there round its tabs; F3 20261023-075225) and its posts from the screen's top as the start menu's, over its rows and slots, which reach the picture's edges), `VIWndMainMenu` with its seven bars, the character's bar, the stats column with `UpdateLocation`'s zone and ninth and the effects, sub-menus over it, `VIKeyFlash`'s repeat; its menus are CLIENT's data, read with gWorldInfo out of the decrypted image (`run/client.bin`; in a page, unpacked off the disc by `dnas`) through its relocations; entries go to `DoAction` or the mode-13 path; Quit asks with the message box, then logs out as `Process` does: 0x09B0 flushed at once, then `Disconnect(9, 1)`, a page's login screen saying "Logged out."). The group's bars (`DrawGroup`, from 0x0772's roster and state channel 0x40) and `VIWndMBox`, the Yes/No box an invitation asks with. The spell book (`VIWndSpellBook`, the start menu's Ability: level tabs, six ability plates (`BlitAbilityIcon`), the selected one's stats and description, the slots, five or ten; its popup Memorize/Arrange/Unmemorize All as `Send0033`/`Send003A`/`Send00FE`, answered by 0x00B4/0x00B6/0x00FF; 0x00FD sets an ability usable). The who list (`VIWndWhoList` over `VIClientPart38334`: 0x0E01/0x0E02 with a serial, ten rows of name, race, class and level, "searching..." while the reply is out; `VIWndOptionList`'s Tell, through the edit box as `/t`, and Invite, `InviteToGroup` with the row's id). `VIWndEdit`, the edit box its entries open (the soft keyboard, or the compact legend for a keyboard, the default here: `EQOA_SOFT_KEYBOARD=1`): Message, buddies, ignore, report. The spell book (`VIWndSpellBook`, the start menu's Ability: level tabs, six ability plates (`BlitAbilityIcon`), the selected one's stats and description, the slots, five or ten; its popup Memorize/Arrange/Unmemorize All as `Send0033`/`Send003A`/`Send00FE`, answered by 0x00B4/0x00B6/0x00FF; 0x00FD sets an ability usable). The inventory (the start menu's "Inventory": `VIWndItemMenu` over `VIWndList`/`VIWndMenu`/`VIWndMiniMenu`, the four tabs `UpdateQuickBars` rebuilds each frame with `VIInfoText`'s predicates, each item's sub-menu, the selected item's description with `StatWithItem`'s "Str: 5 -> 7", `VIWndInspect`, `VIWndQuantity`, the destroy prompt (for every item here: the game asks only for a worn, quick-slotted or NO TRADE one, words 916-918, and an ordinary item gets its unshown word 915, "Destroy item?", the user's choice), the quick-slot mode; item icons from ITEMICON.ESF, "?" where one is missing; the inventory kept by 0x003B-0x0040, 0x000A and 0x0060 with `VIItemList`'s rules; use/equip 0x003E, destroy 0x003C, arrange 0x003D, prepare 0x000A, and the HUD's quick item uses it too). The NPC and player windows: the corpse (`VIWndLoot`, mode 1: 0x0018/0x0016, take one 0x0019, take all 0x001A with `Msg001A`), the merchant (`VIWndMerchant`/`VIWndMercMenu`, mode 10: 0x00B7, buy 0x004A, sell 0x004B with its worn/quick-slotted/no-trade prompt, price, coin and `StatWithItem` lines; in a window wider than 4:3 it stays the game's 4:3 picture as the start menu does, the HUD beside it, a post closing each of its edges from the top rail down to the bottom one, the client's (until 2026-10-23 its left panel, rails and right frames ran to the window's sides)), and trading (`VIWndTrade`/`VIWndTradeList`/`VIWndDeposit`, modes 15/16: 0x0057 and 0x0056's sub-opcodes, `TradeA`/`TradeB`'s numbering, the accept glow, money, chat line); `VIWndQuantity` with its total. Live on a lane server with go-eqoa-server's loot/trade handlers. Mail and the tell forms (`gram.rs`, `mail.rs`): `VIClientPart2EFD0`, the gram client (0x0D01 requests with the inner 0x0D41 as a blob, 0x0D41 replies gated by its state; unit tests on the bytes and the gate), `VIWndComposeGram` (Mail > Compose, mode 23; the reader's and the buddy list's reply copies), `VIWndGramInbox` and `VIWndReadGram` (Mail > Inbox, mode 24: the list, the text, Reply with "Re: ", Delete), `VIWndTell` and `VIWndReply` (modes 28/29, "/t name text" to `ChatLine`); `EQOA_MAIL_TEXT=a|b|c` types the fields in a playshot. Live against a lane server with go-eqoa-server's mail handler: compose, the inbox, read, reply, delete and the unknown-recipient box. | | the quick chat | `quickchat.rs`: L2 in play (the keyboard's Z) opens `VIWndMenu4` (CLIENT, mode 30) over the MemoryDump's quick-chat sets (`LoadTextSets` into `vi::textset`, every dump; the server keeps them): four bars at (175, 18) with the d-pad's arrows, each choice's label (`%W%` its word) with a branch's mark or an empty choice's; the HUD's quick menus stay up under it (L2 opens it without `UpdateMode`) and the effects' bar goes. A tap runs the choice: a line is said (`VIClientText::Expand`'s `%1`..`%4`: the name, level, class and target), `|` is quick command n of 34 (`DoAction`: the four emotes, invite, boot, leave, ignore the target (the edit box with its name), the privacy list, shouts and the guild's chat heard or not (0x0C01's flag, the guild's), say, group, shout and guild, reply, tell, the chat line, the two assists, roll, the pet's seven, follow, the time (`VITimeOfDay::GetLocalTime`, the machine's clock, its weekday as shipped: a leap February is a day out) and the alarm (`VIAlarm`: an edit box for its H:MM, a message box when it rings; `/alarm` and `/time` in the chat line too); Stop Ignoring has no case in `DoAction` and does nothing, as in the game), then the menus close; a branch opens its set in a menu 16 pixels on over this one, which dims to 0.3 (each lights from nothing when it gets the keys), three deep. Held for 16 of the game's frames (about half a second at any display rate) a direction opens `VIWndMenu4Edit` on its choice: the name in its own `VIWndEdit` at (32, 190), the line as a text or one of the 34 commands from a list that glides a row in six frames (the EE's adder: five steps of 0.2 make 0.99999994), a line made a branch or a branch a line, Done or back; Done sends 0x006C (the choice), 0x006A (a new branch's empty set) and 0x006B (each set a branch made a line held); "Def. Menu4" (0x0B080000) puts back the built-in tree (`DefaultTextSets`, its commands numbered as shipped), sent. △, L2, Escape and Z close the menus, Enter and R3 open the chat line in their place, and every other key passes from the window with the keys down the chain to the game window as the game passes it (✕ let go stops the attack). On a pointer a bar is its direction tapped, a mouse press holds it (half a second: the editor), and the editor's rows and command list take clicks; the touch screen's pad keeps its d-pad over a menu. Here and not in the game: a set the dump did not give reads as an empty one (the game reads through a null pointer), and a choice with both a line and a branch runs the line and closes (the game then leaves its hidden branch with the keys). `EQOA_QUICK_TEXT=a\|b` types into the editor's edit box in a playshot | `vi::textset` against CLIENT's own (above). Unit tests: a tap's line and command (and a number past 33 doing nothing), a branch dimming the root and lighting itself, keys passed up (△ closing, L3's release going nowhere), the hold's 16 frames at 30 Hz and on `frames.rs`'s clock at 165 Hz, the editor's rename through its edit box and command pick with the 0x006C it sends, a branch made (0x006C, 0x006A) and dropped (0x006B for its tree), cancel, the list's glide in the EE's six frames (the host's five as the control), the defaults (19 0x006A and the root's four 0x006C), sscanf's `|%d`, a pointer on the bars, the editor's rows and the list (each failing without its hit notes), the alarm and the weekday; the touch pad's d-pad over the menus with another window as the control. Live on the local go-eqoa-server through `EQOA_PAD`: its root (eqoa.live's 25 default sets) drawn, a branch over the dimmed root, "Yes" said and heard back, Wave run and the menus closed, a held branch's "Branch Options", the command list at Cheer, a rename sent as 0x006C (set 1, slot 2, the line and the new label, which its box cut to "I don't ..."); go-eqoa-server does not keep 0x006A/B/C yet, so a change lasts until the next dump | | the updater | `update.rs`, the downloaded builds only: the dev release's `update.txt` (dev.yml's: `version`, `commit`, a `build ` line for linux-x86_64, windows-x86_64 and macos), read on a thread of its own as the game starts; another commit's program downloaded to `.new` (ureq over rustls), held to its size and SHA-256, put in place by two renames (the running one aside to `.old`; the old one back when the second fails), `.lock` keeping a second window of the game out of it; the boot waits for it at its last loading picture (30 s at most, a line on the picture while it downloads), then closes the window and `exec`s (Windows: starts) the new program with the same arguments and `EQOA_UPDATED`, which does not ask again as it starts. While the game runs (`watch`, the program `EQOA_UPDATED` started too) the same thread looks again 3 minutes after the start and every 10 after: another commit's program put in place, or found there (another window's, the start's download that came after the title; not one with this program's own bytes), is offered with what is new in it: the release's `update-notes.md` (dev.yml uploads UPDATE-NOTES.md under that name before `update.txt`, notes.yml again for an edit to the notes alone), read by the same thread, set against the build's own UPDATE-NOTES.md (`include_str!`): the sections it lacks, newest first (`whats_new`; none new, or none read, is a line naming the release page). The question is the game's titled window (`updatewnd.rs`, `VIUIWindow` set 0 as `VIWndMessage`): the sections' titles and bullets in a framed list with its scroll bar (`DrawFrameVScroll`), the pad's ✕ △ ○ □ as the UI's inline glyphs, then Restart now / Later, starting on Later; up once `Play::quiet`/`Pregame::quiet` have held two seconds (the controls let go, sticks included), taking only its own keys (Enter, Escape, the cursor keys, Page Up/Down, Home, End; the pad's ✕, △ and d-pad), the wheel, a stick (the walk's sticks and the look are its while it is up), a drag on the list and a click on a choice; left alone three minutes it is a Later. Restart now logs out as Quit does (0x09B0) and the window closes for the same relaunch, Later asks again after 30 minutes (`EQOA_UPDATE_EVERY=` shortens all three for tests). The channel and commit are baked in by CI (`EQOA_UPDATE_CHANNEL`, `EQOA_BUILD_COMMIT`; dev.yml refuses a program that does not carry its commit); `EQOA_NO_UPDATE`, `EQOA_OFFSCREEN` and the scripted commands leave it out, `EQOA_UPDATE_URL=` reads a server of one's own instead (offscreen too) | unit tests over a server of the test's own: the manifest's lines, another commit put in place (the old aside, nothing left over), this commit, the same bytes under another commit and no answer leaving it, a download of another hash or size or none leaving the program as it was, a second window leaving it to the first, the rollback, the boot's wait; two planted bugs each fail one. End to end on this machine: a build of one commit, started, downloaded a build of another off `python3 -m http.server`, put it in its place and `exec`ed it, which said so and removed the old one; started again, it asked once and stayed. The question while the game runs: unit tests (the watch offering another program put in place or found there, not this commit's nor one with this program's own bytes; the box up only with a program ready and the moment quiet, starting on No, a No keeping it down for its while, a window of the game's putting it away unanswered, left standing a No, a click on its buttons, only its own keys; the pregame's quiet screens), four planted bugs each failing one; `web/pagetest.py`'s Later coming back after its while. End to end, two release builds of different commits and `python3 -m http.server`: at the title the box came up once the served `update.txt` moved on, ✕ on No put it away and left the title's menu as it was, it came back after its while, and Yes started the other build (the old one gone at its start); in the world, against a go-eqoa-server built from its head, Yes logged out (the server's `Logout: … (0x09B0)`) and the new build logged straight back in. What is new in it: unit tests (the notes' sections, bullets joined and markdown out; the sections a build lacks, several when builds were skipped, none, an empty file; the built-in notes reading newest first; the notes fetched beside the manifest, a release without them an error; the window's wrapping, an address broken after a '/'; its list's lines, titles and the line naming the release page; keys, a page, Home and End, a stick and the wheel kept inside the list; Later first; a press on a choice and a drag on the list), five planted bugs each failing one; `web/pagetest.py`'s notice saying what is new (and not when nothing is), a planted bug failing it; dev.yml's publish and notes.yml's step run against a stand-in `gh` (update-notes.md uploaded with the programs before update.txt and kept by the old-asset sweep; again after an edit to the notes alone; nothing when the program changed too). End to end, served notes with two sections newer than the build's: at the title the window listed both (the pad's glyphs inline, the markdown out), a stick scrolled it, ✕ on Later put it away and left the title's menu as it was, it came back, and Restart now started the other build; in the world, the d-pad scrolled it, Restart now logged out (the server's `Logout`) and the new build logged straight back in; a release without the notes showed the line naming the release page and still offered the restart; in a real window on the Xvfb display, the wheel (xdotool) scrolled the list and a drag moved it with the pointer | | reports | `report.rs` natively: the log (stderr through a pipe, `dup2`ed over fd 2 on Unix and `SetStdHandle` on Windows, a thread handing each line on to where stderr went, to `eqoa.log` and to a ring of the last 200; a window past the first, `multibox`'s place, keeps `eqoa-.log`; at the close stderr is put back and the pipe read to its end before the log's last line, `[eqoa] closed`); the panic hook's crash file (`crashes/crash-.txt`: the message, a backtrace, the state last noted, the ring), its message written straight to the terminal, as the pipe's reader is a thread a panic does not wait for; the run before found unclosed by its log; the dialog drawn over the frame through the HUD's `Vui` (F3, the HUD's Report a bug; the keys and clicks its own while it is open), the crash reports waiting offered once a start by the first window; the frame's picture read back before it is presented (the surface made `COPY_SRC` where it allows), at most 1,280 wide, a PNG, sent unless the player turns it off (the row under the name: Tab, then Space, or a click; F3 20261023-083108; a page's box under the note did so already); posted by ureq on a thread of its own. `web/report.js` in a page (its dialog, the canvas's picture, the console's ring, what the page catches), the client handing it its state, the frame drawn, the secrets and the HUD's button through `crate::web`. Both post what reportd takes (`/report`: note, name, version, platform, channel, a PNG in base64; `/crash`); the account name and password the game sent Station (`report::secret`) and the home directory are taken out. `EQOA_REPORTS=` names another server; `EQOA_REPORT_TEST=@` (`EQOA_REPORT_NOPIC` with the picture turned off) and `EQOA_CRASH_TEST=` script a report and a panic | unit tests (JSON strings, base64, the secrets out (a character named after the account keeps its name, a short password stays), a report cut to fit, the dialog's keys, the picture left out by Space and by a click on its row as drawn; each with a control, a planted bug failing); `web/pagetest.py` (27 checks with controls, an unticked picture left out among them, against a stand-in for reportd); natively end to end against a local reportd: a problem report from the licence screen and one from the world, each with its picture and nothing of the account's; a panic's crash report written, offered at the next start, sent and removed; a killed run's found and offered by the next; two windows on one config directory, each with its own log and no crash invented; the HUD's button taken by a playshot's click, a click on the world not | | the options | The start menu's Options (`VIWndOptions`, mode 34, `options.rs`): Game (Faction Messages, Loot Messages, Damage Numbers), Interface (Auto Attack, 3rd Person Camera, Camera Pitch Axis, Soft Keyboard, Vibration), Sound (Battle Music, Fx and Music Volume, Sound Output) and Save Changes (asked with the Yes/No box, "Saving..." four frames); the camera, pitch, volumes, output, music and keyboard change as they are moved, the messages, Auto Attack and Vibration only once saved, and Triangle on the menu puts the moved ones back. Kept as the game keeps `VIGameOptions` (`settings.rs`): `game.opt`, the game's 1,024 bytes (vi.h's layout, little-endian, the magic at +0x3FC) with the login Station's Save Account keeps (+0x10..+0x90) and the server list's home shard (+0x98), read at each login before the options are used (the two sound classes' volumes set from it as `VIWndInitFromMC` sets them; not the output mode, which the console does not set either) and written by Save Changes (`SaveOptions`); natively in `$EQOA_CONFIG`, else `eqoa-rs` in the config directory (`~/.config/eqoa-rs`, `~/Library/Application Support/eqoa-rs` on a Mac, `%APPDATA%\eqoa-rs` on Windows) for `eqoa play` (a `playshot` or `shot` keeps and reads nothing without `$EQOA_CONFIG`), in a page the origin's localStorage (`eqoa.game.opt`), which may refuse (a private window: the defaults, nothing kept). Each window of several keeps its own options by its place (`multibox`; F3 20261023-082517, the user's choice): window 1 in `game.opt` and `client.opt` as ever, window n in `game-.opt` and `client-.opt` (a page's `eqoa.game-.opt`, `eqoa.client-.opt`), so one window plays with sound and another without; a window that has kept none yet starts from window 1's options and home shard, not its saved account (two windows cannot play one account); Controller Focus and Capture Controller stay in `client.opt`, which a later window's save updates, window 1's own lines left as they are; the keys and buttons (`keys.opt`) are the computer's, and a save writes them only when its Controls page changed them, so a window saving its sound never puts back keys another window changed since. The client's own **Extended** page, between Sound and Save Changes (its words the client's, the menu a row longer), kept beside it in `client.opt` and put back by Triangle as the game's are: Level of Detail (On: the game's levels; Off: every LOD body's near level out to its off distance), Brightness (a slider: the scene through a curve, `(level/255)^(2^((5 - step)/5))`, the HUD left as drawn; the middle step no pass at all), Frame Rate (30, 60 or the display's: until chosen, a page on WebGL2 keeps 30, `?uncapped` the display's, and everything else the display's; natively the window sleeps until a capped frame is due) and Camera Distance (a slider: the follow orbit's zoom-out ceiling, the game's 8 to 50 in steps of 3, the client's 11 until moved; the levels and off distances stay measured from the game's farthest camera, `lod::eye`), Camera Turn Speed (a slider: how fast the stick and the keys turn the view, the orbit in the orbiting cameras and the body where the camera is the body's, half to three times the game's in quarters, the game's until moved, bit for bit; the mouse and the touch screen look by the pixel and are not changed, `camstyle::turn_speed`; F3 20261023, Liav's), Screen Shake (On, the game's: the spell effects' shake events shake the view; Off, never: `camstyle::screen_shake` ends a shake before the camera applies it, the eye only; F3 20261023, Liav's), Controller Focus (which controllers this window reads: Auto, Window, Pad 1 to 4 or Off, the row "the pad"; each window's own by its place among the client's windows open, `controller.` in `client.opt`, a save keeping the other windows' as the file has them) and Capture Controller (one setting for every window, `capture_controller=on` in `client.opt` only when on: a Pad that several windows open share drives only the one in front, the others read it at rest until they are, none while none is in front, `input::captured`, this window's own focus test, so it holds on Windows where XInput answers a window behind; a Pad no other window has, and Auto, Window and Off, as ever; off, every window on the Pad moves at once, as before; a save of it in one window reaches the others within half a second, `settings::look_shared` at `multibox`'s look; the user's, 2026-10-23); beside the Back legend, what the window reads now ("Window 1: controller 2 in front"). With eight rows the page starts at its top row and shares the legend's, so it stays nine rows tall under the options window's title tab and clear of the title's menu. The client's own **Controls** page after it (`bindpage.rs`, `binds.rs`; F3 20261021, jib's and dab's; F1 from play opens the window straight on it): a row for each key of the default layout, every one the client's keyboard dispatch takes outside an edit box, with what it does in play (the game's WORDS.TXT words where it has them: Attack, Chat, Map, Reply, Tell...), its key and the pad's button the game pairs it with (`VIWndGame::OnKeyDown`'s case lists: Space ✕, Q □, Tab R1, `` ` `` L1, Z L2, C R2, X L3, Enter R3, Escape Start, P Select; the client's E ○, the arrows the d-pad, W/A/S/D and I/J/K/L the sticks), then Defaults; the selected row's help (what the key and the button are in a window), and two lines that answer the windows' legends for the keyboard: which keys are ✕, △, □, ○, R1 and R3 there now. ✕ on a row's key or button waits ("Press a key"), the next key or button goes there (Escape, the button on △ or a click gives up; F11 and F12 are refused); a key or button another row had swaps with it, said under the list; the sticks stay sticks; △'s row (let the target go), which the game's keyboard has no key for, takes one that presses △ through the pad's path (`SetKey` 132: `ClearTarget` in play, Back in a window). The keyboard moves at the input's edge (`binds::KeyEdge`): a key a row was moved onto is that row's default key to the whole dispatch (play, every window, the map), the row's old key does nothing, a key lets go as what it was pressed as, and text typed into an edit box (the chat line, the editors, the quick chat's) is never remapped; a key moves with all it does (E is the quick menu's use in play and ✕ in a window). The controller's 16 buttons are a permutation onto the game's fields (`binds::PadEdge`, the row "the pad"). The changes apply at once; Save Changes keeps them in `keys.opt` beside `client.opt` (`key.=`, `pad.=