Maps ship their own copy of game bundles such as
bam_levelroot/bam_coregameassets, and that copy loads instead of the game's
on their levels. Changes an asset mod makes to the game's EBX already
propagate into those copies; assets it adds did not. A mod that appends a new
song to the master music playlist therefore left the playlist naming a song
the map's copy never contained, so the song was missing on custom maps.
collect_asset_overrides now also records what an asset mod adds to a game
bundle, and combine inserts it into other mods' copies of the same bundle,
after their own assets, unless the copy already has that name:
- an added EBX is carried only when one of the mod's recorded changes imports
it, or another carried addition does (the playlist names the song, the song
imports its wave), so an addition never travels without the change that
needs it;
- a resource the mod adds under the same name as a carried EBX goes with it
(a wave's sound-bank resource registers the wave; without it the game
crashes instantiating the wave);
- TOC chunks the mod adds that its carried EBX name are listed in each
superbundle those assets go into, pointing at where the mod's archive
landed (a map's superbundle resolves chunks from its own TOC).
A copy in the TOC the adding mod ships its own bundle in (cosmetic mods share one)
merges with that mod's bundle and already has the asset, so only copies in other
superbundles, i.e. maps' level TOCs, receive additions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Show each mod's size in the mod manager
A mod's size was only visible in the package overview and the install
prompt, so there was no telling from either list what a mod costs to
download or how much room the installed ones take.
- MY MODS: each row carries the size of the mod's folder on disk after
the version ("v1.0.1 / 410 MB / by anton / 1 map"), and so does the
line under the title in a mod's overview. The folders are measured
when the Mods folder is scanned, so the figure follows an install, an
update or Refresh. The game's merged patch hard links a mod's archives
rather than copying them, so this is also what removing the mod gives
back.
- GET MODS: each row carries the download size Thunderstore reports for
the latest version, in the same place ("v1.0.0 / 101 MB / 0 downloads
/ 2026-10-04"), as the package overview already did.
- size_text moves out of gui_mods_browse.cpp's file-local namespace so
both pages format a size the same way.
The two figures are not the same thing: one is the unpacked folder, the
other the zip, so a mod reads a little larger once installed.
Two fixes taken from worthlessnorms' pull request 16. The bug it was
opened for, rewritten copies read back from the mod's folder, was fixed
separately in 6fd3845.
Mod merge (Engine/Vfs/mod_merge_cas_store.cpp, mod_merge_combine.cpp)
- To combine two mods' edits the merge reads each mod's copy from the
mod's own folder, which means turning its placement in the merged
patch back into the mod's archive and offset. It took the first of the
mod's blocks in that patch archive. A mod added while the game runs
has all its archives appended to the patch's archive 1, so a copy in
its second archive was looked for in its first, the read failed, and
the asset kept the highest-priority copy.
- unshift() picks the block the offset falls in. Of two blocks that
start at the same byte the later one is used: the earlier is an empty
archive.
- Reproduced with a cosmetic mod rebuilt as two archives and added to a
running merge: cas_main_bundlereftable "could not be combined (Cannot
read ...\cas_01.cas)" before, "combined 2 edits (101 preset(s))"
after. A launch merge gives every archive an index of its own and was
not affected; 40 installed mods merge exactly as before.
- mod_merge_unshift_tests covers an archive with its own index, three
appended blocks, bytes before the first block, and an empty archive.
Tests (CMakeLists.txt, Extension/Multiplayer/Test/mesh_tests.cpp)
- multiplayer_lobbies did not link: steam_lobbies.cpp calls
contains_bad_words and the target did not link dingosdk_word_filter.
- multiplayer_mesh did not link: four functions the session code calls
had no stub. Two are the ones the pull request adds
(update_developer_hoodie, remote_skater_generation); the developer
board has since added update_developer_board and remote_board_entity.
- Both targets build and their tests pass.
Co-authored-by: worthlessnorms <110003169+worthlessnorms@users.noreply.github.com>
With several cosmetic mods installed, some mods' items were missing from
the inventory, and an outfit using one logged it as "no longer
installed".
Bundle merge (Engine/Vfs/mod_merge_combine.cpp)
- Every cosmetic mod adds its items to shared collections
(items/itemmastercollection and one per season). Two mods editing one
collection are combined, but the combine was failing with "Cannot read
<mod>\...\cas_01.cas" and keeping the highest-priority copy, so the
other mods' entries were lost.
- The failed read was not of an edit at all. A mod's untouched copy of
an asset another mod changed is rewritten to carry that change, and
the rewritten copy lives in the merged patch. It was still recorded as
that mod's own contribution, so the combine went to the mod's folder
for it, at the patch's offset, and one bad read made the whole asset
fall back. Renumbered collision resources were recorded the same way.
- A rewritten copy is no longer a contribution. Only the mods that
really edited an asset are combined, and an asset one mod edited is
not "combined" at all.
A mod update was only visible on the mod manager's own page, so the
usual way to find one was a mod behaving oddly in game.
- PLAY carries a pill in its corner while Thunderstore has newer
versions: "1 MOD UPDATE", or the count. Dark on the blue tile, blue on
a grey one.
- Pressing it asks first, listing each mod and the version it would move
to, with three ways on: update them and play, open the mod manager, or
play anyway. Playing on the versions you have is a real choice -- a map
you are part-way through, an update you do not trust yet -- so it stays
offered, and taking it is remembered for the session rather than asked
again on every launch.
- Only from READY. INSTALL, DOWNLOAD, UPDATE and RETRY are untouched: a
mod update has no business standing between someone and a 14 GB game
download.
- The dialog re-checks as it draws. If the pending list emptied while it
was open, it launches instead of showing an empty box.
"Update them and play" launches when the install finishes, which is what
it says and what it did not do:
- The promise is only made if the install really started, so a refused
start cannot launch instantly with nothing updated.
- An update that failed stays on the page with the reason on screen and
logs why it did not launch. Starting the game on a half-updated mod set
is how the cosmetic crash in a30a838 happened.
- Leaving the page with BACK cancels it: that is a change of mind, not a
request to be launched into the game a minute later.
collect_install takes the result under the panel's mutex at the top of
the frame, so once installing is false the outcome is settled and the
error check is reading the real answer.
A cosmetic a mod installs is in no retail catalogue, so it carries no
rarity and its card shows none. Collector is the game's own sixth tier,
already styled and already worn by 395 retail items: it reads as
something apart without claiming to be Legendary, and invents nothing.
- A rarity_id from the content cache or the profile still wins, so
letting mod authors declare one later needs no change here.
- Guarded on catalogs.available: with no pack installed
reserved_cosmetic() answers false for everything, and every retail item
would have turned Collector.
- Build-kit objects share this message and are not cosmetics, so they are
left alone.
With several cosmetic mods installed the game crashed as soon as an
equipped custom item came into view, and so on every launch after it
was equipped.
Cosmetic presets (Engine/Resource/bundle_ref_table.*)
- The character bundle-reference table says which bundle holds each
preset, under the preset's full path and under its leaf name, one row
per hash. Mods number their presets alike in different folders
(cust_tops/1_ap, cust_bottoms/1_ap), so two mods can share a leaf
name. Adding the second preset threw "collides with an existing
lookup", the merge fell back to the highest-priority mod's copy of
the table, and every other cosmetic mod's presets were unregistered.
The game then built the skater with a preset it could not find and
called through a bad pointer.
- A preset whose leaf name is taken is now added under its full path
only; the leaf name stays with the preset that had it. Only a clash
of full paths still fails. MergedTable::shadowed lists each such
preset with the one that holds its name.
Merge log (Engine/Vfs/mod_merge_combine.cpp)
- Each mod with shadowed presets gets a line naming how many and an
example pair, so an item that shows the wrong preset can be traced to
the mods that clash.
Checked against 39 installed mods, 15 of them cosmetic: the table
combines 274 presets where it used to keep one mod's, all reachable by
full path and 267 by leaf name, with the game's own 6264 rows unchanged.
The seven left without a leaf row are a naming clash between the mods
themselves (two Carhartt packs, a Zero tee and deck); whether the game
finds those by full path is not known.
bundle_ref_table_tests takes an optional second mod and checks that two
mods' presets are all added, in either order, and that a shared leaf
name is reported with the preset that holds it.
Videos exported from the replay editor played two to three times too
fast unless the frame rate was capped, or the export was slow enough to
hold it down by itself (4K, WebM). The fault is in the game's own export
path.
Replay export (Extension/Rendering/replay_export.*)
- While exporting, the game steps the replay by one frame of video
(speed / fps) for every frame it renders. The engine's movie encoder
takes a frame only while a permit byte is set; a world update sets it
once, and a frame submitted without it is dropped. Rendering two or
three frames per update, most of a clip never reached the file, and an
MP4 is exactly as long as its frame count. One clip came out as 181
frames at 30 fps and 297 at 60, 6.0 s and 5.0 s.
- The encoder's frame submit is detoured and the permit set before the
original runs, for the replay editor's encoder only (matched by its
vtable). The frame is encoded on the render thread before the call
returns, so the renderer is held to the pace the encoder keeps and no
frame is lost. The replay still runs fast on screen during an export:
that is the encoder's speed, not the file's.
- The same clip afterwards, frame rate uncapped, MP4 1080p: 505 frames
at 30 fps and 1008 at 60, 16.8 s both, audio as long as the video.
WebM and 4K take the same path and were not run again.
- Addresses are in Engine/Game/Build/20260929/replay_export.h. The hook
starts with the others in runtime_init.cpp.
Two follow-ups to the Linux server support in #6. Both are Linux-only, so
neither reached a Windows player.
Session codes
- nonce()'s fallback for a container with no /dev/urandom read
"i < 4 && !value", which ended the loop as soon as the first round
produced anything: one round of sixteen bits instead of four. Over
200k draws that is 62k distinct codes with a ceiling of 0xffff, rather
than 200k across the full range.
libsteam_api.so
- It was fetched from a mirror's main branch with no hash, by CI and by
the script every server operator runs, and it is loaded into the
server process. A branch name is whatever that branch holds on the day
it runs.
- Pinned to mirror commit df2baabf574a and checked against its SHA-256
(385840 bytes, eb2dd015...). The size and export checks already there
catch a wrong file; a hash is what catches a swapped one.
- --sdk-url still overrides the URL and drops the check, since a
different URL has a different hash; --sdk-sha256 keeps it.
- Conflict in launcher_support.h: kept Linux guards, took upstream's
validated_remote_load_library(note) signature + comment.
- Upstream's path_utf8() helper is portable; merged cleanly into the
Linux closure. Build + 8/8 portable ctest green.
A blank launcher window (Launcher/gui_renderer.cpp, Launcher/gui.cpp)
Reported as a launcher that showed nothing, from someone with a GT 1030.
The log named the card it had used: Intel HD Graphics 4600, the onboard
chip beside it.
- D3D12CreateDevice(nullptr, ...) takes whichever adapter DXGI lists
first, which on a PC with onboard graphics beside a real card is the
onboard one. It now walks EnumAdapterByGpuPreference with
HIGH_PERFORMANCE, skips software adapters and takes the first that
gives a device, as a game would. The old default adapter and WARP
remain behind it, so no working setup loses its device.
- That chip's Direct3D 12 driver cannot build a pipeline, and the ImGui
backend creates one on the first frame while ignoring whether it
worked (imgui_impl_dx12.cpp:792). Every draw then does nothing and the
window shows only ClearRenderTargetView's colour, which is near black.
Untextured shapes sample the font atlas's white pixel, so a failed
atlas looks identical -- the drawn background disappears with the
text. The pipeline and font texture are built in init now and checked,
and a failure says to update the driver and that the card may be below
what ReSkate and skate. need.
- None of this was diagnosable, because the renderer logged nothing at
all. It now records the adapter, its memory and whether it is the
software fallback; the window size, scale, system DPI and work area;
the font atlas size and whether it built; and the first frame drawn.
Those four lines are what turned "blank window, healthy log" into the
two bugs above, and they separate a missing device from a missing
pipeline from a window that draws and is still black.
World layers (Engine/Vfs/world_layer_scan.cpp)
- CoreGameAssets had a row like any other, and switching it off took the
world away with no clue which row had done it. essential_layer keeps
its node, so its children are still found and still switchable, and
drops its row -- which removes it from the menu, from "world <key>
off" and from layer sync at once, rather than hiding it in one place
and leaving it reachable from another.
- Choices are stored by row key, not by index, so no saved setting
shifts, and an "off" already stored for it has no row to apply to: a
profile that turned it off fixes itself on the next launch.
- The cache stamp goes to 2, so a cache written before this is rebuilt
instead of keeping the row alive on existing installs.
- The scan test asserted one row per node, which this breaks on purpose.
It now checks the counts differ, that no row's key holds
"coregameassets", and that the node is still there. Against a real
install: 262 rows to 261, six maps, passing.
Mod archives (Engine/Vfs/mod_merge*)
- A free archive index no longer has to sit at or below the highest index
the layout already declares. The engine keeps archives in a table keyed
by layer, install chunk and a 16-bit index, with no range to stay
inside; what it cannot survive is a reference to an archive the layout
never declared. Indices are taken lowest first, per package directory.
- So a mod that collides takes an index of its own and its file is hard
linked rather than concatenated, and no two mods share one archive's
4 GB of addressable bytes. Sharing is left for a mod added while the
game runs, which has no declared index to take, and for a directory
with no index left.
- A placement addresses its payload with a 32-bit offset, so both append
paths now refuse to cross 4 GB instead of wrapping round and pointing
the game at some other payload: CasStore::write fails with the archive
that is full, and the live-apply path checks archive 1's size plus the
mod's before appending and asks for a restart, which gives the mod an
archive of its own anyway.
Injection through a hooked LoadLibraryW (Engine/Core/Platform)
- A player could not launch at all: "Child LoadLibraryW entry bytes match
neither Windows' kernel32 file nor this process". Anti-virus and
overlays hook the entry with a jump to a per-process trampoline, so the
bytes differ in the launcher and in the child and neither comparison
could match. The guard already allowed the same hook when both
processes carried it identically, so it refused on an accident of how
the hook was written rather than on intent.
- inline_jump_length recognises the forms a hooking engine writes (rel32
jmp or call, push/ret, jmp [rip], mov rax/jmp rax, short jmp). When one
is present and every byte after it still matches the clean kernel32
file, the launch goes ahead: the body is Windows' own, and the provider
image already matched on timestamp, checksum and size. A wholesale
replacement is still refused, and now says so.
- The failure names the modules loaded into Skate that came from neither
Windows nor the game folder, instead of guessing at "security
software". An accepted hook is logged as a warning with the same list.
Launching (Launcher/launch.cpp)
- Exporting loose scripts is a convenience, and a folder that would not
take it was failing the whole launch: fs::create_directories threw and
nothing caught it. The export is skipped, RESKATE_LOOSE_FILES goes to
0 so the runtime does not look for files that are not there, and Skate
starts. Someone whose install is under Program Files can play; only
editing loose scripts is lost.
Failures worth reading (Launcher/problem.h)
- A hooked LoadLibraryW, a refused write and ERROR_ELEVATION_REQUIRED
each explain themselves now. Windows reports the last two in the user's
own language -- "Zugriff verweigert", "La operación solicitada requiere
elevación" -- so the rules key on the operation names and the error
number, which are never translated.
Saved skaters (Extension/Customization, Extension/Profile)
- Outfits stopped saving and every slot showed the standard skater. The
load hook marks a preset blocked on entry and clears it only on a full
success, so a load refused for a catalog that was not ready yet left
the preset blocked for the session and every later save was refused as
previous_load_rejected. The game asks once per level load and does not
ask again, so one lost race cost a skater until a restart. Logs show
the catalog arriving three seconds after the refused load, at startup
and again on a map switch 17 minutes later.
- The record is remembered in CosmeticRuntime::pending_loads and applied
by retry_pending_cosmetic_loads from the customization pump, which
already waits for refresh_cosmetic_catalog. The preset stays blocked
until a load really succeeds, so nothing is written over the outfit on
disk.
- Those records belong to the loadout manager's array, which can be
replaced in between, so each pointer is checked against the live array
exactly as a save checks one -- manager current, begin/end sane, 0x48
stride, at most 10 records, in range and aligned. A record that no
longer belongs is dropped as retry_record_moved rather than written
through.
A player whose game sat under a folder named U+200E (left-to-right mark,
an invisible directory on their Desktop) could not launch: the status
tile read "No mapping for the Unicode character exists in the target
multi-byte code page". Nothing was wrong with their mods, and the merge
in their log had just succeeded.
fs::path::string() converts to the process's ANSI code page and throws
exactly that when a character has no mapping there. initfs.cpp called it
on the normal path, not in an error handler, so exporting loose files
threw on every launch. Their log stops right where that export begins.
Cyrillic, CJK or accented characters in an install path do the same on a
code page that cannot hold them.
- path_utf8() in Engine/Core/Platform/path_text.h: UTF-8 represents every
path Windows allows, so it never throws.
- Every path that becomes a narrow string uses it -- 35 of them across 13
files. Most are log lines and error messages, which were a second trap:
reporting a problem with a path threw this instead of the problem.
- The ~45 .string() calls on Json values are untouched; those are not
paths.
Nothing changes for an ASCII path, where both encodings agree, and a path
that is not ASCII had no working behaviour to preserve.
Reported with a log by a player on Discord. Moving the game to an ordinary
folder works around it on an unpatched build.
Fetch libsteam_api.so (SDK redist mirror, export-verified) and
steamclient+tier0/vstdlib (SteamCMD app 1007) and stage them into
ReSkateServer-Linux-<version>, mirroring the Windows zip's DLLs.
A mod the merge could not use was left out silently. The game started
without it, three minutes into a loading screen, and only ReSkate.log said
which one.
Merging before launch
- PLAY runs the merge the game would have run behind its splash, on the
launcher's worker, with the step and progress on the STATUS tile
(Phase::merging). The launcher links dingosdk_mods for it, which costs
about 600 KB of exe.
- When every mod merges, the launch goes ahead and the game's own
load_catalog finds the patch already built, so the splash is shorter
rather than the work being done twice.
- When mods were left out, the launch stops at Phase::mods_broken and a
dialog names each one and its first problem, with three ways on: switch
them off and play (which writes mods.json, so the next launch does not
rediscover it the hard way), open the mod manager, or play anyway.
- Mods built for another game version do not block. They already have
their own notice and message box, and blocking on them would stop play
after every Steam update.
- The launcher resolves -dataPath out of the extra-arguments setting, so
it merges against the root the game will read.
One SDK identity for both binaries
- Two places recorded "which ReSkate did this" as the module the code was
running in, which is ReSkate.dll in the game and ReSkateLauncher.exe in
the launcher now that both merge. merge_fingerprint hashes it, so the
game matched no stamp the launcher wrote and merged everything a second
time; sdk_identity keys the exclusions file, so each would have called
the other's exclusions stale.
- Both now name ReSkate.dll beside the running executable. Skate.exe and
ReSkateLauncher.exe sit beside it and launch.cpp enforces that, so the
two agree, and unlike the data root it does not move under -dataPath.
The value is unchanged from what the runtime computed before, so nothing
re-merges on upgrade.
Naming the mod
- In game, a mod left out of the merge altogether said nothing on screen:
only a mod that merged partially did, which is the lesser failure. Both
are named now.
- The MOD MANAGER tile says which mod did not load, in the detail line's
colour rather than a pill, clipped so a long name cannot run under the
tile's artwork.
- MY MODS carries a line naming them above the list, and NOT LOADED and
OUTDATED are badges on the row instead of words at the front of its
summary.
MY MODS reaches Thunderstore parity
- Rows carry the package's icon, the same one the store shows, through a
shared mod_icon(); a mod that never came from Thunderstore keeps the
placeholder.
- An update is a button on the row, not a badge you had to open the mod to
act on, and every row has Uninstall. Rows grew to fit, and "Remove" is
"Uninstall" throughout.
- No pill for a mod that is up to date: the absence of an update button
says it, and eight green pills said nothing eight times.
Fixes
- The install panel was drawn on the Mods page's own draw list after its
children, and ImGui renders a child after its parent, so it was painted
behind the mod rows along with its Cancel button: installs ran with no
sign of it. It is its own window over the page now.
- pump_icons, which uploads decoded icons, only ran on the store page, so
MY MODS showed placeholders until a visit to GET MODS uploaded them for
it. The page does it for both lists.
- The merge dialog's closing advice overlapped its buttons.
Changing a setting in the game's menu did not stick. The camera went
back to high on leaving the menu, grind assist and lean exit stayed at
20 / on, the compass and scoring ticker kept their old state, and the
Experimental tab's "reset to default" returned to whatever was set at
launch.
b763631 gave the settings hooks per-thread caches of saved values that
look a value up again only when profile::Store::changes() advances.
Only whole-profile commits advanced it. Every single save (a setting,
an option, an outfit) goes through Store::Update::commit(), which did
not, so the caches answered with launch-time values for the rest of
the session. native_setting_get also wrote the cached value back over
the new one when the two differed. The saves themselves reached the
database, so profiles are intact.
- Update::commit() advances the counter through Store::note_change(),
after value_ holds the new values and before the lock is released.
Store::commit() and the constructor use it too.
- A cache hit needs the whole key text, not the key's address and first
8 bytes. Many keys share those bytes (egf_enable..., egf_exp_...,
CRAS_HasSeen...Tab), so a reused address could answer for another
setting. Still no heap string, store lock or map walk on a hit.
- An outfit that is not loaded or not saved is logged as a warning with
its reason and preset (local_cosmetic_loadout_rejected). It was a
debug event, so a player's log never said why an outfit was refused.
Routine cases stay at debug.
- dingosdk_profile_change_tests (with the park editor tests): each kind
of save advances changes(), an unchanged save does not, and saved
values keep their types across a restart.
Control pipes driven by one-shot writers (echo cmd > fifo, fleet
tools) close the write end after each line; read()==0 then means
'no writers right now', not EOF. Only treat 0 as end-of-input for
non-FIFOs (/dev/null, files, terminals). Sleep briefly on empty
FIFO polls so POLLHUP does not spin.
Under Steam + Proton on Linux the launcher always reported Steam as
offline, so the game ran on the offline stand-in and servers could not be
joined. steam_signed_in() needs ActiveProcess\ActiveUser, and Proton's
stand-in steam.exe only writes ActiveProcess\pid.
- When ActiveUser is missing and the launcher is running under Proton,
the account comes from the native Steam client instead: the most recent
entry in config/loginusers.vdf under STEAM_COMPAT_CLIENT_INSTALL_PATH,
which Steam sets for compatibility tools and Wine's
wine_get_dos_file_name maps to a DOS path.
- The Steam name on the launcher's plate and the SteamID that --offline
reports are read from that directory too. Proton's
C:\Program Files (x86)\Steam holds no account files, so offline mode
fell back to 76561197960265729.
- loginusers.vdf's MostRecent key is matched in either case. Steam has
also written it as "mostrecent", which picked the first account in the
file instead of the most recent.
Windows is unchanged: the variable and the Wine export are both absent
there, so ActiveUser is still required.
Diagnosed, with a confirmed workaround and the suggested fix, by
BenjaminJAnderson.
FixesDingo-Shenanigans/ReSkate#5
Co-authored-by: BenjaminJAnderson <45207455+BenjaminJAnderson@users.noreply.github.com>
The mod browser was a 960x620 panel inside a 1180x680 window, reached from
a tile the same size as SETTINGS. People could not find it, and once they
did there was room for about six mods.
Window
- The design size is 1440x840. Everything is laid out in design units and
multiplied by g_scale, which now scales down when that would not fit the
screen's work area, so a small laptop or a high DPI no longer gets a
window running off under the taskbar.
- The MODS tile is MOD MANAGER: taller than SETTINGS, directly above PLAY,
with the pending update count as a pill on its subtitle line.
- background.jpg is 1920x1027, so the bigger window is not upscaling a
1280x720 photo.
Mod manager, a page rather than a panel
- begin_page fills the window and draws its own minimise and close, since
it covers the main screen's. BACK or Escape leaves it.
- A nav rail holds MY MODS and GET MODS (count badges, blue for the open
one), the install actions and BACK. Refresh sits with what it refreshes,
top right of the content.
- Every mod is a full-width row: icon, name, author, description, version
and downloads, with a blue INSTALL or UPDATE and a tick box on the right.
Ticking several installs them in one run.
- Clicking a row opens that mod's overview in a popup, replacing the
side-by-side details pane that halved the list's width.
- virtual_rows draws only the rows a scrolling child actually shows.
ImGuiListClipper cannot do this, as it assumes every row is a fixed
height.
- Installing is a centred panel over a dimmed page with the activity, a
striped bar and Cancel, instead of a progress bar in the footer.
Settings
- The same page and rail: GAME, DISPLAY, KEYS and ADVANCED are nav tiles
rather than a row of tab buttons, and the content keeps to a 760 px
column so the explanations stay a readable measure.
- The Steam sign-in, Steam Guard and QR dialogs stay centred panels. They
are prompts, not pages.
Status and failures
- The STATUS tile carries its state in a coloured edge and a pill (WORKING,
READY, ACTION NEEDED, PROBLEM), its headline is 22 pt over muted detail,
and the standing facts (game build, ReSkate version) sit under a rule
instead of competing with the live status as equal rows.
- Launcher::fail keeps the raw message for the log, and problem.h turns it
into a headline and the next thing to try. The injection and PE-identity
failures ("VirtualAllocEx failed", "Injected ReSkate.dll is not present
in the child module list") all become "ReSkate could not attach to
Skate", with the anti-virus and overlay advice that actually fixes it;
the Steam download, a missing or damaged ReSkate.dll, the content cache
and a shared menu/console key each get their own. Anything unmatched
keeps its raw text.
- A failed tile grows Open logs and Copy details. Copy details puts the raw
message and the versions on the clipboard, ready to paste into Discord.
- console reader: poll()+read() instead of iostream getline; joinable
thread joined before Steam shutdown/exit (iostream lock deadlock
needed Ctrl+D before)
- README: Linux dedicated-server setup guide
- CI: version input, tag builds, ReSkateServer-Linux-<version> asset
with setup script staged at top level
- workflow built only dingosdk_server so ctest binaries missing (Not Run)
- build all targets before ctest
- layers: read works on Linux, export needs Windows game + Oodle;
document player-cache copy path
Only replace a nested metadata file when the package supplies a matching
top-level file. Cover ZIP and folder installs with partial metadata and
case-insensitive package overrides.
Mesh streaming pool (fixes loading crashes on older GPUs)
- 1.0.0 forced MeshStreaming.PoolSize to 3.5 GiB on every card. An RX 580
crashed while loading with DXGI_ERROR_DEVICE_RESET, and the oversized pool
is the likely cause. The pool is now a quarter of the largest hardware
adapter's dedicated video memory, capped at 3.5 GiB; cards of 6 GB or less
keep the game's own 520 MB. The card, its memory and the chosen size are
logged once at startup.
Remote players (busy servers)
- Transform interpolate is inline plain arithmetic: 22 -> 5.8 ns per joint.
It was 4% of a busy server's client frame, since every player within
200 m has the whole skeleton interpolated every frame.
- Players out of the camera's view and more than 8 m away are sampled at
20 Hz, like far players. Their position still updates every frame for
collision, sound and nametags, and a player coming into view is sampled
that frame.
Expression hooks (run for every expression the game executes)
- No profiler zones in the expression hooks: taking a sample switched them
on, inflating the very samples that measured the hooks.
- The throwdown lab pump does one sorted lookup of the running graph and
skips everything else for graphs it doesn't watch (graph_throttle gets
throttled_graph for this).
Settings and memory reads
- Script gameplay-setting and native-setting gets are served from small
per-thread caches, keyed on the asset/key address, its first 8 name
bytes and a new profile::Store::changes() counter, instead of a store
lookup each call.
- Per-frame reads in the client tick, native settings and population
controls use memory::peek instead of ReadProcessMemory.
- Script error logging probes each argument pointer once.
Launcher
- "Close the launcher when Skate starts" now hides the launcher and exits
when Skate.exe does, instead of exiting as the splash opens. Outside a
Steam library, Skate's protection closed the game (TerminateProcess 0x32)
when the launcher exited during startup; players confirmed turning the
setting off avoided it.
- STATUS tile warns when Steam isn't open or signed in that PLAY starts
offline as Unknown Player (hidden when Offline mode is chosen or while
launching).
- Setting description updated to match.
Startup interventions (20260929)
- exit_return 0x87755f2 -> 0x87fe7fb: the last LEA RSP,[RSP]; JMP stub
before the PUSHFQ / LOCK CMPXCHG routine, as 0x87367cf was in 20260908,
and the return address player logs show. Only enforced once a frame site
is armed; this build's protector has no equivalent rep stosb frame clear.
Client frame cost (profiled 2026-10-02)
- Transform interpolate is inline plain arithmetic (~1% of an MP frame).
- Script gameplay-setting and native-setting gets are served from small
per-thread caches keyed on asset/key address, name prefix and a new
profile::Store::changes() counter, instead of a store lookup each call.
- Hot-path reads in client tick, native settings and population controls
use memory::peek instead of ReadProcessMemory.
- Script error logging probes each argument pointer once.