Commit Graph

464 Commits

Author SHA1 Message Date
Aaron Kimbrell
ebcfd54915 fix: tell the player when the zone they go to gets no instance
The master server dropped a transfer request it started no instance
for, so the world server never heard back and the player waited. It
now answers the request with an empty address and the reason in place
of the port: no room for another instance (max_instances), a zone that
is not in the ZoneTable, or another reason. A request for a private
zone that does not exist is answered the same way.

The world server passes this on as a TRANSFER_TO_WORLD without an
address, which the client shows as a failed map change, and leaves the
character where it is. The auth server fails the login when there is
no character select to send the player to.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:28:34 -06:00
Aaron Kimbrell
24294561ae fix: a changed trade offer takes both accepts back
A player could change their offer after the other player gave the final
accept, and the trade completed with the new offer on their own accept.
Both clients drop their accepts when an offer changes, the one that
changed it and the one that receives the update, so the trade now does
the same and both players have to accept the offers as they are.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:28:34 -06:00
Aaron Kimbrell
6f7fd5f84e fix: a trade offer takes what the trade window takes
The client's trade window has 8 slots for each player and takes neither
a bound item nor an equipped one. An offer with more than 8 items, a
bound item or an equipped item is now refused like the other offers the
window can not make, when it arrives and again when the trade
completes.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:28:34 -06:00
Aaron Kimbrell
e164f9aaed fix: the master server only starts instances of zones in the ZoneTable
A transfer or private zone request started a world server for whatever
map ID it named, and for as many instances as were asked for. A new
instance now needs a row in the ZoneTable for its zone, and no more
than max_instances (masterconfig.ini, 256 by default, 0 for no limit)
run at once. The character select has no row and is always started.

A request that gets no instance is logged and dropped. Before, the
handlers went on to use the missing instance.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:28:34 -06:00
Aaron Kimbrell
b3f5cd74df fix: a vendor sells up to 999 of an item at once
The limit was 1000. The client's buy dialog counts up to 999 for an
item from a vendor, whatever the item's stack size is, so that is the
most a purchase can ask for.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:28:34 -06:00
Aaron Kimbrell
5ff45d9a90 fix: adapt the session key packets and the packet decoder to the 33 character key
SetSessionKey, SessionKeyResponse and NewSessionAlert carry the key as
a 33 character string where the number was, which the auth, master and
world servers now pass. The packet decoder blanks that string in a
kept packet, and the packet tests write the string.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:28:34 -06:00
Aaron Kimbrell
26b188852f fix: session keys are 16 bytes sent as 32 hex characters
The key was a 32 bit number, sent to the client as the MD5 hash of its
decimal text. The client only stores the key and sends it back, so it
is now 16 bytes from the system entropy source written as 32 lowercase
hex characters, which fills the same 33 character field of the login
response.

The auth, master and world servers pass the key itself between them as
a 33 character string in place of the number: SET_SESSION_KEY,
SESSION_KEY_RESPONSE and NEW_SESSION_ALERT keep their IDs and carry the
string where the number was. The master server keeps one key for each
username, and a world server compares the key a client sends with the
one from the master server in constant time. A key stays valid until
the next login of its account replaces it, as before.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:28:34 -06:00
Aaron Kimbrell
1ba8f26d2b fix: check a length read from a message against the data left in it
Several game messages read a 32 bit length and then read that many
characters without checking it, so the loop ran for the whole length
even after the message had ended. The length now has to fit in the
data the message has left. This covers the activity state, cinematic
path, death type, removed item info and inventory group messages and
the shared wide string reader.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:28:34 -06:00
Aaron Kimbrell
43cea3a245 fix: limit the chunks and the inflated size of an sd0 buffer
An sd0 buffer could hold any number of chunks, and each chunk grew the
inflated data by a full chunk whatever it held, so the data of a chunk
that was not full was followed by a gap. A buffer now holds at most
4096 chunks and inflates to at most 64 MiB, and every chunk is inflated
right after the one before it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:28:34 -06:00
Aaron Kimbrell
a235e57975 fix: limit how deep AMF3 arrays nest and stop at the end of the data
The AMF3 reader read an array inside an array to any depth and kept
reading values after the data had ended. It now refuses arrays nested
more than 64 deep and throws when the data ends before a value does.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:28:34 -06:00
Aaron Kimbrell
2d24608186 fix: pay a vendor sale by the items that left the inventory
Selling paid for the count in the message while the move stopped at
what the stack held. The count now has to be between 1 and the stack's
count, the item can not be one the vendor already holds for buy back,
and the coins and alternate currency are worked out from the number of
items that left the inventory.

Buying back needs an item in the buy back inventory and a count the
stack holds. Both prices are worked out as 64 bit numbers.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:04:30 -06:00
Aaron Kimbrell
c0b7a8f643 fix: check a trade offer against the items the player holds
A trade used the LOT and the count from the offer as the player sent
it. The offer is now resolved when it arrives and again when the trade
completes: the LOT comes from the held item, the count stops at the
held count, and an offer that names an unknown item, the same item
twice or a count of 0 is refused. The other player only receives an
item that was taken from the offering player.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:03:21 -06:00
Aaron Kimbrell
b88168f2ed fix: route only the chat messages a client sends
A world server passed every routed packet on to the chat server under
whatever message ID it carried. It now passes on the messages a client
sends, and SHOW_ALL only for the level the showall command needs. The
message ID is read as the full 32 bit number, the packet has to be long
enough to hold a chat packet and the sender needs a character.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:02:22 -06:00
Aaron Kimbrell
e8479cd6e5 fix: generate session keys from the system entropy source
The key came from the shared game random engine, which is seeded from
the start time. It now comes from std::random_device through a small
helper. The key stays a 32 bit number, so the packets do not change.

The master and world servers no longer write the key or its hash to
the log.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:01:35 -06:00
Aaron Kimbrell
8a1d278265 fix: the zone keeps its paths after the merge, and the tests follow the merged readers
- Zone::LoadZoneIntoMemory takes the paths the zone file reader read (lost in the merge of the raw terrain loading)
- the terrain reader is main's unchanged: files from before version 32 keep their colour and texture maps as the file
  has them, and the dashboard's terrain tests expect that (bounds by the span between the first and last point too)
- the model component test counts the model block only: the item block in front of it is the ItemComponent's
- scene file tests: a file from before chunks keeps only its header, editor settings are a count of colours, and the
  tests that log have a logger
- the settings catalog lists export_terrain_to_obj

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:14:23 -06:00
Aaron Kimbrell
ba90898b75 fix: adapt the zone file tests to a scene object's kept render techniques
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:04:03 -06:00
Aaron Kimbrell
d870207a9f fix: place brick by brick models by the bottom middle of their bricks' box
When a brick by brick save is split into models, each model was centred on
the middle of its bones' origins, on the lowest origin. A brick's origin is at
a corner of it, not its middle, so a single 2x2 came back offset by half a
brick, and the model's floor was taken from the origins rather than from the
bricks.

Now each model is placed as the live server placed it: at the bottom middle
of the box around its bricks. Each brick's own box comes from the client's
brickdb by its design ID and is turned and moved by its bone (all 8 corners),
and the model's box holds every brick's. Its x and z middle and its lowest y
are the new origin, still snapped to the 0.8 grid on x and z, and every bone
moves by that; rigids and rotations are untouched. A design the brickdb has no
box for counts its bones' origins, as before, so no brick is left out.

Lxfml::NormalizePosition and Split take the boxes by design ID, and the brick
by brick save passes the ones read from the client (read on the first save,
then kept).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:52:30 -06:00
Aaron Kimbrell
ba41bf0311 feat: read the bounding boxes of the client's bricks from its brickdb
BrickBounds reads the <Bounding><AABB/> of every Primitives/<designID>.xml in
the client's res/brickdb.zip (through the asset manager, so packed and
unpacked clients both work), by design ID. A design listed only as another
primitive's alias gets that primitive's box. Get() reads the file the first
time it is called and keeps the boxes for every later call.

The zip is read by a small reader in dCommon (Zip::ForEachEntry) that handles
stored and deflated entries, with a raw inflate added to ZCompression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:52:20 -06:00
Aaron Kimbrell
a163264adf fix: splitting and normalizing models moves every bone, in file order
Lxfml::NormalizePosition moved only the first bone of each part, so after a
save a flexible part's other bones kept their world positions while the rest
of the model was moved to its own origin. Now every Bone moves by the same
amount, and the box that places the model holds every bone. Split maps every
bone of a part too, so a rigid system holding only a later bone keeps its
brick.

Only bones move, as the live server did: the Rigid transformations are sent
back as the client wrote them, and rotations are not touched.

Also:
- a model with no readable bone is kept as it is at the origin instead of
  getting a center 10000 up
- the math is done in doubles and numbers are written as the shortest text
  that reads back as the same float (-0.4, not -0.400002; 12.1678, not
  12.1677)
- bricks and rigid systems come out in the file's order, so the same model
  always splits into the same bytes
- parts over 5 MB are normalized like any other (the input is capped at
  10 MB), and the loop's safety limit no longer drops every later model

The pivot is still snapped to the 0.8 grid (the bricks don't move in the
world; the model's position stays on the grid), which can put it half a
stud from the middle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:52:08 -06:00
Aaron Kimbrell
70e0163af4 fix: split and adjust scene object config as the client does
LwoNameValue::FromString (0x010fa220) splits a config at every comma
and line break and leaves out entries without a '='; the world split
only at line breaks. Objects and sounds are now split the client's way.

ReadLvlObjectData (0x0103ba20) also adjusts an object's config as it
loads it, and now so does the world:
- before version 47 a respawn time was in milliseconds: a float over
  100, or any u32, becomes seconds (a float);
- a model spawner (LOT 176 spawning template 14) gets its blueprint
  from its subkey unless it has one, behaviors off unless it says
  otherwise, and no render wrapping;
- before version 44 an object that overrides its scene goes to layer 0
  of the scene it names.

Check in game: every world spawns its objects as before; spawner
respawn times are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:51:49 -06:00
Aaron Kimbrell
7a90869ebd fix: skip the wide null after a scene file particle's effect names
Before version 46 the u16 after a particle's effect names is the names'
wide null terminator, 0 in every shipped scene file. It is skipped
instead of kept as an unknown value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:51:49 -06:00
Aaron Kimbrell
c33d74f8c6 feat: read and keep a scene file's sounds and particles as the client does
Files from before chunks now read their sounds and particles after the
objects, and chunked files their particle chunk:
- sounds (SceneLoader::LoadSounds, version 5 on, only before chunks):
  position and config (before 35 a 260 character name, kept as
  soundName);
- particles (SceneLoader::LoadParticles, version 5 on): priority (43
  on), position, rotation, effect names and config (46 on; before, an
  unused u16); the client stops at one with no effect, and before 44 a
  file may end before them.

Every scene file of every client on disk reads each part to the next
part's start (up to the client's 16 byte padding): 5408 referenced by
zone files and 5125 on disk, versions 31-43 before chunks and 44-49
chunked. One client file, a LUP world, ends before its sounds; its
objects are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:51:49 -06:00
Aaron Kimbrell
d06cc2923e fix: read a scene file's chunks, environment and objects as the client does
The world read only a scene's objects and skipped the rest with
file.ignore. Level now reads a .lvl the way SceneLoader::LoadLvlFile
does (1.10.64) and keeps what it reads.

Chunked files: the file info chunk gives where the environment, object
and particle chunks start (the chunks were walked in order before); the
environment chunk gives where the lighting, skydome and editor settings
start, and the client reads all three only when there is lighting.

Files from before chunks: the no-editor-settings byte only from version
32, the revision from 37, a file newer than 48 is not read, a file
older than 3 has no objects; then lighting, skydome, editor settings
and objects in turn.

Kept, by version:
- lighting: blend time (45 on, default 10), ambient, specular, upper
  hemisphere, position, min and max draw distances (31 on; before 39
  fog near and far only), cull settings (40 on), fog colour (31 on),
  directional colour (36 on), and before 42 the zone's spawn point
  (before 33 the fixed one the client uses);
- the skydome and its five other files (34 on; they were skipped from
  33);
- the editor's colours (37 on, the first 32);
- each object's node type (38 on), glom ID (32 on) and render
  techniques (7 on: a 64 byte name, then per technique a 64 byte name,
  u32, u8 and 16 floats); before, a count of techniques was read and
  the techniques themselves were not, so an object with any misread
  the rest of the file.

Object config is split at line breaks as before. A file that ends early
keeps the objects read before its end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:51:49 -06:00
Aaron Kimbrell
9367ad6207 refactor: look up the oldest zone files' scenes in a CDClient SceneTable table
Zone held a std::function that queried the SceneTable, so tests could swap it out. CDSceneTableTable now loads the table with the others, and Zone looks scenes up in it; tests fill its entries like the zone table's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:46:33 -06:00
Aaron Kimbrell
d0d280d3ad fix: read and keep a zone file's scenes as the client does
LuzFile::ReadLUZFile, LuzReader::ReadScenes and ZoneLoader::ReadZoneFile
(1.10.64):
- a version older than 20 is read as 20;
- before version 30 a scene is a bare u32 SceneTable ID: in ID order,
  each one with a SceneTable row gets the next index (-1 past 255) as
  its ID and the row's sceneName as its file; the others are left out;
- from 30 to 32 the file has no scene IDs: a scene's ID is its index in
  the file (they all had ID 0, colliding);
- a later scene with the same ID and layer replaces an earlier one, a
  scene ID with no layer 0 scene is left out, and before version 41
  only the layer 0 scenes are loaded.

No 1.10.64 zone file has a duplicate scene, a scene without a layer 0
scene or another layer before version 41, so what live zones load is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:46:25 -06:00
Aaron Kimbrell
c1dea4205b fix: read a zone's paths as the client does
LuzFile::ReadLUZFile copies the path chunk by its size and ReadLUZPaths
reads it with LevelPath::FromBuffer (1.10.64). Reading as they do:
- the paths are read from their chunk, never past its end;
- a chunk version of 2 or more, 10000 paths or more, or a path the
  client refuses (version 19 or more, 10000 waypoints or more, more
  than 99 name/value pairs on a waypoint) leaves the zone with no
  paths, logged; the rest of the zone file still reads;
- before version 3 a path's type is a name ("platform" or a movement
  path) and every waypoint has a rotation, the moving platform values
  and a name/value list, whatever its type;
- property headers are there from version 3, spawner and camera
  headers from version 4;
- a camera waypoint's FOV before version 11 is divided by 1.25;
- a property path's reputation multiplier outside 0-1000 becomes 0.

Every path chunk of every client's zone files on disk (858) reads to
its end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:46:10 -06:00
Aaron Kimbrell
254f9b9919 fix: a zone file's boundaries and scene colours are named and kept as main has them
ZoneBoundary's destinationZone and destinationSceneID and the scene's NiColor, so main's scene tools and tests read
the zone this branch's reader loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:41:35 -06:00
Aaron Kimbrell
19368f6259 fix: adapt main's place model response test to the message struct
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:39:43 -06:00
Aaron Kimbrell
b2dbaeb0ab fix: the dashboard tests compile the terrain reader they test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:30:05 -06:00
Aaron Kimbrell
fcf8591749 fix: one raw terrain reader, in dZoneManager, after the merge
The dashboard's copy of the terrain reader in dCommon is gone; main's in dZoneManager reads for everyone, and keeps
what the copy had over it: colour and texture maps from before version 32 become RGBA, cut to the resolution the
client keeps. The scene map (ZoneScenes) and the dCommon tests compile against that reader.

A scene ID takes its layer as the zone file numbers it (0 General, 1 Audio) as well as the enum, so main's scene tools
and tests build; /getscene prints the layer as a number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:19:33 -06:00
Aaron Kimbrell
b466c08ee2 Merge origin/main into experimental: behaviors, raw terrain scene lookups, property model fixes, player count
Main's model behaviors (every rotation, DoDamage, OnTimer, OnStartup, MoveBackToStart, moves along the model's
local axes), raw terrain reading with scene lookups and scene tools, property model and brick by brick fixes and the
chat server's player count fix, merged onto this branch's structures:

- game messages stay structs; the angular velocity messages join them and the handler sets a message's target
- strips own their speed again and the block names, moves and rotations stay in the shared action table
  (PropertyBehaviorActions, which the dashboard's behavior player reads; it now lists rotations too)
- the zone keeps reading its file through ZoneFile and takes the boundaries from it, then loads the raw terrain as main
  does; the old dNavigation terrain reader is gone
- the show all response stays a struct and logs a player count mismatch as main does
- the property management component tells models who sent a chat message

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:57:45 -06:00
Aaron Kimbrell
0ff5d2bf87 fix: property model and brick by brick fixes (#2041)
* test: the server mock keeps a copy of the last bitstream sent

The bitstreams sent are usually on the sender's stack, so the pointer it kept was dangling once Send returned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: PlaceModelResponse writes the model's rotation

The client's PlaceModelResponse::Deserialize (0x00dc0170) reads the rotation as an optional w, x, y, z quaternion, but the server wrote the 4-byte response after the rotation flag, so with any rotation other than identity the client ran out of data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: naming a model no longer renames the property

The model info window sends UpdatePropertyOrModelForFilterCheck with isProperty false (client 0x00cc42e0); only the property editor sends it true (0x00c2e710). A model's name and description aren't stored yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep a model item's blueprint across saves

A brick built model item's config has blueprintid (lowercase), which the saved item XML didn't keep (only blueprintID), so a picked up model lost its blueprint on the next load and could not be placed again. It is now saved as bp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: write model behaviors only for property and inventory models

The client makes the mutable model component, which reads the behaviors and editing info, only when the object's config has propertyObjectID or inInventory (ObjectLoader2::LoadModelBehaviorsComponent); every other model gets the plain one, which reads only the model block. Writing the behaviors for every model made the client read them as the next component's data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: the model vault uses the models' inventory groups, as the client

LWOInventoryComponent_Common::msgUpdateInventoryGroup (0x00c955b0) and msgUpdateInventoryGroupContents (0x00ce1f50) look the inventory type up in m_VaultToPlayerInventory first, which maps VAULT_MODELS to MODELS. The server refused group updates for the model vault.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:34:31 -05:00
Aaron Kimbrell
9bf2ff4d44 feat: Raw reading on zone load and scene tools (#1910)
* feat: raw terrain parsing for scene data

Replace old dNavigation/dTerrain raw parser with new Raw module in
dZoneManager. Parse heightmaps, color maps, and scene maps from .raw
files to determine which scene a position belongs to. Build scene
adjacency graph from terrain data and scene transitions.

Adds NiColor type, SceneColor lookup table, eSceneType enum, terrain
mesh generation with OBJ export, and debug slash commands for scene
visualization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: deduplicate terrain grid lookup logic, remove unnecessary cast

Add IsValidForSceneLookup(), GetSceneIDAtGrid(), and GridToWorldPos()
to Raw::Chunk. Use them in GenerateTerrainMesh, GetSceneIDFromPosition,
SpawnScenePoints, and SpawnAllScenePoints instead of duplicated inline
math. Remove unnecessary static_cast in LoadSceneTransitionInfo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add version < 32 support to raw terrain parser

The parser was only correct for v32 files. For v<32:
- colorMapResolution is width-1, not width
- Color map reads width*width*4 bytes (BGRA per-pixel), not colorMapRes^2*4
- No DDS lightmap
- No texture settings byte or blend DDS
- Scene map: v31 reads (colorMapRes+1)^2 cells with border skip,
  v<31 skips 1 byte
- No mesh data

Verified against all 309 .raw files across multiple client versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: log and reject legacy raw terrain format, clean up ReadRaw API

Log a clear message and throw if the zone uses a legacy raw format
(LUZ version < 30) since no known maps use it. Remove ReadLegacyRaw
and the mapVersion parameter from ReadRaw.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: revert v33 scene field renames to unknown1/unknown2

These fields are skipped by the client in every known build (ptr += 12
and ptr += 4). No client ever reads them into a struct. Keep the
original unknown1/unknown2 names from main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: load the zone without scene lookups when its raw terrain is missing or unreadable

Log at debug when it parses; build the terrain mesh only for the OBJ export; read the zone name and description only for version 31 and later, as the client does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: look up the scene at a position as the client does, in constant time

Chunk bounds span (width - 1) * scale; the nearest scene map cell is used and the far edge is kept one cell inside. Chunks are indexed by grid cell when they form a grid. Skipping reads instead of seeking, so a cut-off file can't seek past the end. Terrain mesh indices follow the vertex order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: scene point commands take a step and use brick LOTs from the BrickIDTable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: rename NiColor::ToXYZ to Luminance

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: raw terrain reading, scene lookup, scene graph and scene commands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: close the OBJ file before removing it so the test passes on Windows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the zone boundary lines and read the luz scene count as the client does

Each boundary line's destination is read as a map ID and an instance ID into a LWOZONEID. The scene count is a byte before version 37 and a u32 from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: name the fourth flair colour byte padding

The client never sets or reads it, so it holds leftover memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the scene map of version 31 and older raws

These files have a (resolution + 1)^2 scene map, kept in sceneMapLegacy. Version 31 uses its first resolution rows and columns; older versions are all scene 0, as in the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: simplify the raw heightmap size check

The dimensions are capped at kMaxResolution, so the product can't overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: remove the scene point spawn commands and send /getadjacentscenes as one message

The spawn commands were only for testing. /getscene and /getadjacentscenes stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-27 09:22:28 -05:00
Aaron Kimbrell
58f8789255 feat(dashboard): database usage report: growth, fastest tables, disk warning
Pure helpers for the Database usage page, unit tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:57:55 -05:00
Aaron Kimbrell
3daff99556 feat(db): measure the database's size per table and keep the samples
IDatabaseUsage: MySQL/MariaDB from information_schema.TABLES (fresh
statistics on MySQL 8), SQLite from its page counts and the dbstat table
(now compiled in) with counted rows. Samples go to database_usage and
database_usage_tables (migrations 105 / 88).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:57:55 -05:00
Aaron Kimbrell
c42a7ba387 fix(missions): a mission is only turned in when its rewards fit, as the client
LWOMissionComponent::MissionWindowOK (0x00d501f0) turns a mission in only
when CanReceiveAllRewards (msgCanReceiveAllRewards 0x00d17e20,
sub_00d17750) passes for the reward the player picked: the reward items
(the repeat ones once completed, each at least 1) must fit after the items
the tasks take (a gather task's target unless it keeps them, one of a
delivery task's target) with the first completion's bag rewards added.
The fit is LWOInventoryComponent_Common::CheckCanAddItems (0x00d53de0):
turned in items leave first (an item of 1 settles the whole count), a
unique item fails while one is held outside the buyback, then each item
tops up stacks of its LOT (stack size 0 is no limit, LookupItemInfo
0x00c1c190) and takes free slots.

MissionDialogueOK now refuses a turn-in that does not pass, which the
client never sends. The client's max currency part is not checked: the
server writes no cm, so the client has no limit either.
InventoryComponent::GetDefaultInventorySize is split out of GetInventory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:48:44 -05:00
Aaron Kimbrell
2fe489d518 fix(missions): accepting a repeat waits whole minutes of cooldown, as the client
The client's cooldown (LWOMissionComponent sub_00cd7770) counts whole
minutes since the last completion and has none when Missions.cooldownTime
is 0 or below. Accepting used seconds and treated only -1 as no cooldown,
so a cooldown of 0 still refused in the second of completion and other
negative values refused for a while. CanAccept now uses the same check as
the offers (MissionComponent::IsCooldownRunning).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:48:44 -05:00
Aaron Kimbrell
c1079fc20c fix(missions): prerequisites read as the client reads them
The client checks Missions.prereqMissionID in
LWOMissionComponent::getMissionState (0x00cd7400): text without any of
!&^;,|(): is one mission ID (met when 0 or ever completed); anything else
goes through its expression reader (sub_00a32980), left to right with no
precedence, & , ; and, | or, ^ xor, ! ~ not, ( ) groups, and/or skipping
the rest once the result is known. Each operand (sub_00c90a80): "id" is
met once the mission was ever completed; "id:state" when the client's
state mask of that mission equals state.

DLU read the text right to left ignoring groups, so
"(509|229)&(812|813|814|815)" (mission 781) was met by 509 alone; a plain
ID needed the mission's state to be 8, so a repeatable being repeated
blocked the missions after it (1870 after 1869); and id:state compared the
mission's own state. GetClientMissionState moves to MissionPrerequisites
so operands can ask for the mask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:48:44 -05:00
Aaron Kimbrell
529bacd077 feat(missions): keep the viewed flag of missions in progress, as the client
The client keeps a viewed flag on each mission in progress, sets it with
SetMissionViewed (LWOMissionComponent::msgSetMissionViewed 0x00bec7a0, only
for missions in progress) and reads it as v on the mission's <cur> entry
(LWOMissionComponent::LoadFromSaveData 0x00d171c0, a number or "true", false
when missing). The server now handles SetMissionViewed, saves v="1" for a
viewed mission in progress and loads it back; saves without v load as not
viewed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:48:43 -05:00
Aaron Kimbrell
02c96b08bd fix(inventory): nothing is used while stunned from using items, as the client
LWOInventoryComponent_Common::msgUseNonEquipmentItem (0x00cdeba0) asks
GetStunned first and uses nothing when canUseItem is false
(LWOControllablePhysComponent::msgGetStunned 0x00be0df0: stunned from
using items, or dead). The server now counts the use-item stun of every
SetStunned it sends for an entity as msgSetStunned (0x00bf1d10) does
(push/pop, never below 0, characters only, unless immune and not
bIgnoreImmunity), and checks it with IsDead before using an item. The
server never checked either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:55:06 -05:00
Aaron Kimbrell
a538f32382 fix(inventory): the charxml's items element has nn="1", as live
Live charxmls carried <items nn="1"> (225 of 226 captured).
LWOInventoryComponent_Common::LoadFromSaveData (0x00d1ec40) reads it and
then does not add the inventory component's template items to the loaded
character. No effect on players today (the player LOT's inventory
component has no items); the server never wrote it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:55:06 -05:00
Aaron Kimbrell
df3f46b43b fix(character): the character data packet sends txfring=true, as live
LWOInventoryComponent_Client::LoadConfigData (0x00cde200) reads txfring
from the character data; without it the client runs its inventory-full
tips (msgPlayerReady 0x00bde5e0) on every world load. Of 226 captured
live character data packets, 222 had the key, all true; the server never
wrote it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:55:06 -05:00
Aaron Kimbrell
0b5a79cf96 fix(inventory): inventory groups keep their locked flag, as the client
LWOInventoryComponent_Common::msgUpdateInventoryGroup (0x00c955b0) stores
the ADD's locked flag in the group; locked groups don't count toward the
50 user groups (msgGetInventoryGroupCount 0x00bde4b0 counts only unlocked
ones) and msgUpdateInventoryGroupContents (0x00ce1f50) applies the
1000-LOT cap only to unlocked groups. LoadFromSaveData (0x00d1ec40) reads
it from the grp element's u attribute. The server dropped the flag and
always saved u="0"; saves without u load as unlocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:55:06 -05:00
Aaron Kimbrell
841004e6a4 fix(inventory): an inventory group holds at most 1000 LOTs, as the client
LWOInventoryComponent_Common::msgUpdateInventoryGroupContents (0x00ce1f50)
ignores an ADD to an unlocked group that already holds 1000 LOTs. The
server had no cap, so a modified client could grow the charxml without
limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:55:06 -05:00
Aaron Kimbrell
042594e3dd fix(inventory): UpdateInventoryGroupContents CLEAR empties the group, as the client
LWOInventoryComponent_Common::msgUpdateInventoryGroupContents (0x00ce1f50)
takes ADD, REMOVE and CLEAR; CLEAR removes every LOT from the group. The
server logged CLEAR as an invalid action and kept the LOTs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:55:06 -05:00
Aaron Kimbrell
a3d1b6a8b8 fix(inventory): the model vault uses the models' inventory groups, as the client
LWOInventoryComponent_Common::msgUpdateInventoryGroup (0x00c955b0) and
msgUpdateInventoryGroupContents (0x00ce1f50) look the inventory type up in
m_VaultToPlayerInventory first, which Initialize (0x00d55a60) fills with
VAULT_MODELS -> MODELS. The server refused group updates for the model
vault.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:55:06 -05:00
Aaron Kimbrell
9afe8ccedb fix(inventory): inventory group names are cut to 64 characters, as the client
LWOInventoryComponent_Common::msgUpdateInventoryGroup (0x00c955b0) resizes
the group name to 64 characters on ADD and MODIFY. The server kept names
of any length in the charxml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:55:06 -05:00
Aaron Kimbrell
0bc1a88e75 fix(inventory): adding an inventory group that exists does nothing, as the client
LWOInventoryComponent_Common::msgUpdateInventoryGroup (0x00c955b0) ignores
an ADD whose group id is already in the inventory. The server added a
second group with the same id, saved to the charxml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:55:06 -05:00
Aaron Kimbrell
ba6228ef09 fix(inventory): MoveItemInInventory moves only where the item belongs, as the client
LWOInventoryComponent_Common::msgMoveItemInInventory (0x00d20590) looks the
item up in the inventory the message names and moves it only to the
inventory its LOT belongs in (GetInventoryTypeForLOT 0x00c3c4f0), or for
the vaults (VAULT_ITEMS, VAULT_MODELS) the LOT's vault type
(GetValueTypeForLOT 0x00c3c4a0). An item in the target slot swaps into the
moved item's old slot and inventory, and must belong there. The server
moved an item into any inventory destInvType named and left the swapped
item in the destination. Live clients never set destInvType (0 of 61 in
the captures).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:55:06 -05:00
Aaron Kimbrell
7366799152 fix(inventory): deleting skips equipped items, as the client
LWOInventoryComponent_Common::DoRemoveItemFromInventory (0x00d1fb50)
passes over equipped items when the loot source is LOOT_SOURCE_DELETION.
The server deleted the item while it was still worn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:55:06 -05:00