mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 02:47:01 +02:00
After 10 bails the leg of the side that took more of them shows road dust and a graze, its arm one bail later; at 14 the marks are bigger and the other side starts; at 20 they are raw wounds and the face is bruised. A mark never gets smaller until the player heals. The worst wounds bleed, or are dry and scabbed with the blood switch off. The marks are cosmetic items of a content mod (tattoo items for the limbs, moles items for the face, named RoadRash_<Set>_[<design>_]<level>). Road Rash puts them into the local skater's recipe with the game's own recipe copy, after the validation the multiplayer code runs on a peer's outfit. Nothing is written to the outfit or the profile's skater, and without the item pack nothing shows and nothing is changed. A bail is the physics state selector choosing Wipeout (No Bail's watch). Its side comes from the ragdoll's contacts through Hall of Meat's reader: impact and slide of the arm and leg bodies that touch something other than the skater's own board, summed per side while the body is a ragdoll. Outfit menus. The game feeds a skater's items either from the player's outfit or from a recipe of the skater's own: after the recipe copy the skater in the world no longer gets what the player changes in the menus. So the skater is given back to the game while a game menu is up, with nothing to show, on healing, when a switch changes and when switched off: the four words of the item component the copy had changed go back to what they were and the component is told its feed changed, and the game puts the outfit's own items back and feeds the skater again. The marks go back on, over the outfit as it is by then, a second after the menu has gone. Whose recipe a skater wears is read from the skater at every look: a respawn hands over the same skater, items and all, as if it were new. The player's own tattoos and moles stay unless roadrashtattoos is on, and come back the same way. The recipe handed to the copy names no item, only hashes, like the game's own for the local skater: the game changes that skater's items where they lie, and a name left beside one would stop every later read of the recipe, the multiplayer code's too. Console: roadrash, roadrashblood, roadrashtattoos, roadrash heal, roadrash bails <count>. Menus: Road Rash, Road Rash blood and Heal Road Rash beside Hall of Meat (Mod Options > Player, the overlay's skater page, the native tools view). The three choices are saved with the profile. No new hooks. The rules (side of a fall, levels per count, item names, decal placement, comparing parameters the game has rounded) are in road_rash_model.h with their own test target, DINGOSDK_BUILD_ROAD_RASH_TESTS. Notes and what is not tried yet: contrib/road-rash.md.
270 lines
18 KiB
C++
270 lines
18 KiB
C++
#include "Extension/UI/NativeMenu/native_tools_view.h"
|
|
#include <cstdio>
|
|
#include <stdexcept>
|
|
|
|
using namespace dingosdk;
|
|
using namespace dingosdk::native_tools;
|
|
void check(bool condition, const char* message) { if (!condition) throw std::runtime_error(message); }
|
|
struct Capture {
|
|
unsigned calls{};
|
|
std::string command, root, destination, spawn;
|
|
overlay::DebugRequest debug;
|
|
};
|
|
int main() {
|
|
try {
|
|
{
|
|
// A BAM root with its seven time-of-day layers, as the level scan finds them.
|
|
WorldLayerCatalog catalog{{{"Levels/Game/BAM_LevelRoot/Root", WorldMap::bam, -1, true}}, {0}, {}};
|
|
for (const auto* time : time_of_day_keys) {
|
|
const auto slot = static_cast<unsigned>(catalog.nodes.size());
|
|
catalog.nodes.push_back({std::string("Levels/Game/BAM_LevelRoot/TOD_") + time, WorldMap::bam, 0, false});
|
|
catalog.layers.push_back({std::string("bam_tod_") + time, time, "", WorldMap::bam, slot, slot, "Lighting"});
|
|
}
|
|
install_world_layer_catalog(std::move(catalog));
|
|
}
|
|
State state;
|
|
overlay::Model model;
|
|
Capture capture;
|
|
overlay::CallbacksV3 cb;
|
|
cb.user = &capture;
|
|
cb.queue_load = [](void* user, const char* root, const char*, const char* destination, const char* spawn, char*, std::size_t) {
|
|
auto& c = *static_cast<Capture*>(user); ++c.calls; c.root = root; c.destination = destination; c.spawn = spawn; return true;
|
|
};
|
|
cb.queue_debug = [](void* user, const overlay::DebugRequest& request, char*, std::size_t) {
|
|
auto& c = *static_cast<Capture*>(user); ++c.calls; c.debug = request; return true;
|
|
};
|
|
cb.queue_console_command = [](void* user, const char* command, char*, std::size_t) {
|
|
auto& c = *static_cast<Capture*>(user); ++c.calls; c.command = command; return true;
|
|
};
|
|
model.levels = {{std::string(root_asset), "Root"}, {"Studio/Example", "Studio", {"EntryA"}, {}, false}};
|
|
model.can_queue_load = true;
|
|
activate(state, model, cb, "select-level", "Studio/Example");
|
|
check(capture.calls == 0 && state.destination == "Studio/Example", "Selecting a detached level must not load it");
|
|
activate(state, model, cb, "load-level", "");
|
|
check(capture.calls == 1 && capture.root == root_asset && capture.destination == "Studio/Example" && capture.spawn == "EntryA", "Detached level uses its sole manifest spawn automatically");
|
|
model.levels.back().native_registered = true;
|
|
activate(state, model, cb, "load-level", "");
|
|
check(capture.calls == 2 && capture.spawn.empty(), "Registered level uses the engine's default spawn without retaining a previous selection");
|
|
check(state.feedback.empty(), "Accepted changes do not display queue diagnostics");
|
|
model.multiplayer.active = true;
|
|
check(!can_load(state, model, cb), "Guest cannot load a level even with an otherwise ready loader");
|
|
const auto guest_page = render(state, model, cb, 0);
|
|
check(std::none_of(guest_page.main.begin(), guest_page.main.end(), [](const auto& row) {
|
|
return row.command == "select-level";
|
|
}), "Guest level choices are visibly locked");
|
|
check(std::none_of(guest_page.side.begin(), guest_page.side.end(), [](const auto& row) {
|
|
return row.command == "load-level";
|
|
}), "Guest load button has no activation delegate");
|
|
activate(state, model, cb, "load-level", "");
|
|
check(capture.calls == 2 && state.feedback == "Only the lobby host can change levels.",
|
|
"A stale level action cannot load after joining a host");
|
|
model.multiplayer.hosting = true;
|
|
check(can_load(state, model, cb), "Host retains level controls");
|
|
model.multiplayer = {};
|
|
check(can_load(state, model, cb), "Level controls return when the host ends the lobby");
|
|
model.multiplayer.lobby_joining = true;
|
|
check(!can_load(state, model, cb), "Joining cannot race a manual level change");
|
|
model.multiplayer = {};
|
|
model.can_queue_load = false;
|
|
activate(state, model, cb, "load-level", "");
|
|
check(capture.calls == 2, "Stale load button cannot queue during transition");
|
|
model.can_queue_load = true;
|
|
model.levels.pop_back();
|
|
activate(state, model, cb, "load-level", "");
|
|
check(capture.calls == 2 && state.destination.empty(), "Removed level selection must not load");
|
|
model.world.map = WorldMap::bam; model.parks.available = true;
|
|
activate(state, model, cb, "park-lot", "financial");
|
|
activate(state, model, cb, "park-family", "");
|
|
activate(state, model, cb, "load-park", "");
|
|
check(capture.command == "park financial flumppark_01", "Selected park targets its own lot");
|
|
const auto calls = capture.calls;
|
|
model.parks.controlled_by_host = true;
|
|
activate(state, model, cb, "load-park", "");
|
|
check(capture.calls == calls, "Host ownership is rechecked on activation");
|
|
activate(state, model, cb, "noclip", "");
|
|
check(capture.calls == calls, "Unavailable debug control cannot queue");
|
|
model.debug.noclip = true;
|
|
activate(state, model, cb, "noclip", "");
|
|
check(capture.debug.action == overlay::DebugAction::set_noclip && !capture.debug.enabled, "Active noclip can always be disabled");
|
|
model.graphics.available = true; model.graphics.ready[0] = true; model.graphics.enabled[0] = false;
|
|
activate(state, model, cb, "graphics", "film_grain");
|
|
check(capture.command == "graphics film_grain 1", "Toggle reads effective authored graphic state");
|
|
model.progression.challenges_enabled = true; model.progression.challenges_hidden = true;
|
|
activate(state, model, cb, "challenges", "");
|
|
check(capture.command == "challenges 1", "Hidden challenges use show command");
|
|
model.world.ready = true; model.world_controls.population_available = true;
|
|
model.world_controls.choices.traffic = 3;
|
|
activate(state, model, cb, "traffic", "");
|
|
check(capture.command == "environment traffic -1", "Population cycles back to authored default");
|
|
const auto locked_parks = render(state, model, cb, parks_section);
|
|
const auto load = std::find_if(locked_parks.side.begin(), locked_parks.side.end(), [](const auto& row) { return row.id == "load-park"; });
|
|
check(load != locked_parks.side.end() && load->command.empty(), "Host-owned park action is visibly unavailable");
|
|
cb.queue_debug = [](void*, const overlay::DebugRequest&, char* result, std::size_t size) {
|
|
std::snprintf(result, size, "Queued for the game update thread."); return true;
|
|
};
|
|
activate(state, model, cb, "noclip", "");
|
|
check(state.feedback.empty(), "Successful callback diagnostics stay out of the menu");
|
|
model.debug.status = "Native callback diagnostic";
|
|
const auto player_page = render(state, model, cb, player_section);
|
|
check(std::none_of(player_page.side.begin(), player_page.side.end(), [](const auto& row) {
|
|
return row.title.find("Native callback") != std::string::npos;
|
|
}), "Debug model status stays out of the player menu");
|
|
const auto find = [](const std::vector<Row>& rows, std::string_view id) {
|
|
return std::find_if(rows.begin(), rows.end(), [&](const auto& row) { return row.id == id; });
|
|
};
|
|
// Hall of Meat sits with the player's other switches.
|
|
const auto meat_row = [&] {
|
|
const auto page = render(state, model, cb, player_section);
|
|
return *find(page.main, "hall-of-meat");
|
|
};
|
|
check(meat_row().title == "Hall of Meat: Off" && meat_row().command.empty(), "Hall of Meat waits until it started");
|
|
model.hall_of_meat = {true, false};
|
|
activate(state, model, cb, "hall-of-meat", "");
|
|
check(meat_row().command == "hall-of-meat" && capture.command == "hallofmeat 1", "Hall of Meat turns on");
|
|
model.hall_of_meat = {true, true};
|
|
activate(state, model, cb, "hall-of-meat", "");
|
|
check(meat_row().title == "Hall of Meat: On" && capture.command == "hallofmeat 0", "and off again");
|
|
// Road Rash's switches sit there too; its blood and its healing wait for it to be on.
|
|
const auto rash_row = [&](std::string_view id) {
|
|
const auto page = render(state, model, cb, player_section);
|
|
return *find(page.main, id);
|
|
};
|
|
check(rash_row("road-rash").title == "Road Rash: Off" && rash_row("road-rash").command.empty(), "Road Rash waits until it started");
|
|
model.road_rash = {true, false, true};
|
|
check(rash_row("road-rash-blood").command.empty() && rash_row("road-rash-heal").command.empty(), "Its blood and healing wait for it");
|
|
activate(state, model, cb, "road-rash", "");
|
|
check(rash_row("road-rash").command == "road-rash" && capture.command == "roadrash 1", "Road Rash turns on");
|
|
model.road_rash = {true, true, true};
|
|
check(rash_row("road-rash").title == "Road Rash: On" && rash_row("road-rash-blood").title == "Road Rash blood: On", "and shows it");
|
|
activate(state, model, cb, "road-rash-blood", "");
|
|
check(capture.command == "roadrashblood 0", "The blood switches off");
|
|
activate(state, model, cb, "road-rash-heal", "");
|
|
check(capture.command == "roadrash heal", "and the skater heals");
|
|
model.multiplayer.local_name = "steam_name";
|
|
model.player_card = {true, "Card Name", {}};
|
|
const auto card_page = render(state, model, cb, player_section);
|
|
const auto field = find(card_page.side, "card-name");
|
|
check(field != card_page.side.end() && field->input && field->title == "steam_name" && field->argument == "Card Name",
|
|
"Card name field shows the Steam name as its hint and the saved name as its text");
|
|
check(find(card_page.side, "card-name-reset")->command == "card-name-reset", "A custom card name can be reset");
|
|
model.player_card.custom_name.clear();
|
|
const auto steam_page = render(state, model, cb, player_section);
|
|
check(find(steam_page.side, "card-name-reset")->command.empty(), "Steam name reset is idle without a custom name");
|
|
{
|
|
State parks;
|
|
overlay::Model m;
|
|
m.world.map = WorldMap::bam;
|
|
m.parks.available = true;
|
|
m.parks.choices.fill("flumppark_01");
|
|
Capture log;
|
|
auto controls = cb;
|
|
controls.user = &log;
|
|
const auto control = [&](const Page& page, std::string_view id) -> const Row& {
|
|
const auto found = find(page.main, id);
|
|
check(found != page.main.end() && found->button, "Random park controls are controller-focusable buttons");
|
|
return *found;
|
|
};
|
|
auto page = render(parks, m, controls, parks_section);
|
|
check(control(page, "load-random-parks").title == "Load Random Parks" &&
|
|
control(page, "load-random-parks").command == "load-random-parks", "Controller exposes random loading for all lots");
|
|
check(control(page, "park-random-on-launch").title == "Randomize on Launch: Off", "Launch toggle reads the shared preference");
|
|
parks.parks.fill("megapark_01");
|
|
activate(parks, m, controls, "load-random-parks", {});
|
|
check(log.calls == 1 && log.command == "park random", "Random loading queues one shared command, not three separate rolls");
|
|
check(parks.parks == ParkChoices{}, "Accepted random loading discards every staged layout");
|
|
sync(parks, m);
|
|
check(parks.parks == m.parks.choices, "A reroll of the same saved layouts still replaces staged selections");
|
|
|
|
m.parks.controlled_by_host = true;
|
|
page = render(parks, m, controls, parks_section);
|
|
check(control(page, "load-random-parks").command.empty(), "Guests cannot activate random loading");
|
|
activate(parks, m, controls, "load-random-parks", {});
|
|
check(log.calls == 1, "A stale random button cannot load after joining a host");
|
|
activate(parks, m, controls, "park-random-on-launch", {});
|
|
check(log.calls == 2 && log.command == "park random-on-launch 1", "Guests can save their own next-launch preference");
|
|
m.parks.randomize_on_launch = true;
|
|
page = render(parks, m, controls, parks_section);
|
|
check(control(page, "park-random-on-launch").title == "Randomize on Launch: On", "Controller reflects changes from the other menu");
|
|
activate(parks, m, controls, "park-random-on-launch", {});
|
|
check(log.calls == 3 && log.command == "park random-on-launch 0", "Launch toggle uses the latest shared state");
|
|
|
|
m.multiplayer.server_admin = true;
|
|
page = render(parks, m, controls, parks_section);
|
|
check(!control(page, "load-random-parks").command.empty(), "Dedicated-server admins keep random park controls");
|
|
activate(parks, m, controls, "load-random-parks", {});
|
|
check(log.calls == 4 && log.command == "park random", "Server admin sends one shared command for server-side routing");
|
|
m.world.map = WorldMap::grom;
|
|
activate(parks, m, controls, "load-random-parks", {});
|
|
check(log.calls == 4, "Random loading is unavailable on maps without these park lots");
|
|
m.world.map = WorldMap::bam;
|
|
m.parks.available = false;
|
|
activate(parks, m, controls, "load-random-parks", {});
|
|
activate(parks, m, controls, "park-random-on-launch", {});
|
|
check(log.calls == 4, "Unavailable park controls cannot queue actions");
|
|
m.parks.available = true;
|
|
controls.queue_console_command = nullptr;
|
|
page = render(parks, m, controls, parks_section);
|
|
check(control(page, "load-random-parks").command.empty() && control(page, "park-random-on-launch").command.empty(),
|
|
"Missing command callbacks disable both controls");
|
|
controls.queue_console_command = [](void*, const char*, char*, std::size_t) { return false; };
|
|
parks.parks.fill("megapark_01");
|
|
activate(parks, m, controls, "load-random-parks", {});
|
|
check(parks.parks[0] == "megapark_01" && !parks.feedback.empty(), "A rejected queue retains staged choices and reports failure");
|
|
}
|
|
{
|
|
// Time of day forces one of a map's seven time layers, others off.
|
|
overlay::Model world;
|
|
world.world.available = world.world.ready = true;
|
|
world.world.map = WorldMap::bam;
|
|
world.world.supported.assign(world_layers().size(), true);
|
|
State tod;
|
|
Capture log;
|
|
overlay::CallbacksV3 record;
|
|
record.user = &log;
|
|
record.queue_console_command = [](void* user, const char* command, char*, std::size_t) {
|
|
auto& c = *static_cast<Capture*>(user); ++c.calls; c.command = command; return true;
|
|
};
|
|
check(time_of_day_available(world, record) && time_of_day(world) == 0, "Time of day starts at the level's default");
|
|
activate(tod, world, record, "time-of-day", {});
|
|
check(log.calls == 7 && log.command == "world bam_tod_1_morning on", "Cycling selects Morning last, after the others are off");
|
|
for (unsigned slot = 0; slot < time_of_day_keys.size(); ++slot)
|
|
world.world.choices[time_of_day_layer(WorldMap::bam, slot)] = slot == 4 ? "on" : "off";
|
|
check(time_of_day(world) == 5, "A single forced layer reads back as that time");
|
|
const auto page = render(tod, world, record, 0);
|
|
check(std::any_of(page.side.begin(), page.side.end(), [](const auto& row) { return row.title == "Time of day: Night"; }),
|
|
"The World page shows the current time of day");
|
|
for (unsigned slot = 0; slot < 6; ++slot) activate(tod, world, record, "time-of-day", {});
|
|
world.world.choices[time_of_day_layer(WorldMap::bam, 6)] = "on";
|
|
check(time_of_day(world) == 0, "Two forced times read as no single choice");
|
|
for (auto& choice : world.world.choices) choice = "default";
|
|
world.world.choices[time_of_day_layer(WorldMap::bam, 6)] = "on";
|
|
for (unsigned slot = 0; slot < 6; ++slot) world.world.choices[time_of_day_layer(WorldMap::bam, slot)] = "off";
|
|
log.calls = 0;
|
|
activate(tod, world, record, "time-of-day", {});
|
|
check(log.calls == 7 && log.command.ends_with(" default"), "Cycling past the last time returns all seven to default");
|
|
world.world.controlled_by_host = true;
|
|
log.calls = 0;
|
|
activate(tod, world, record, "time-of-day", {});
|
|
check(log.calls == 0 && !time_of_day_available(world, record), "A host-forced lobby cannot change time of day");
|
|
}
|
|
{
|
|
overlay::Model maps;
|
|
maps.levels = {{std::string(root_asset), "Root"}, {"levels/Game/BAM_LevelRoot/BAM_LevelRoot"},
|
|
{"levels/Game/DingoLevel_Splash/DingoLevel_Splash"}, {"Levels/Custom/Bedroom", "Bedroom"}};
|
|
maps.levels.back().custom = true;
|
|
State split;
|
|
const auto shows = [](const Page& page, std::string_view asset) {
|
|
return std::any_of(page.main.begin(), page.main.end(), [&](const auto& row) { return row.argument == asset; });
|
|
};
|
|
const auto world_page = render(split, maps, cb, world_section), custom_page = render(split, maps, cb, custom_section);
|
|
check(shows(world_page, maps.levels[1].asset) && !shows(world_page, "Levels/Custom/Bedroom"), "World lists only official levels");
|
|
check(shows(custom_page, "Levels/Custom/Bedroom") && !shows(custom_page, maps.levels[1].asset), "Custom Maps lists only mod levels");
|
|
check(!shows(world_page, maps.levels[2].asset) && !shows(custom_page, maps.levels[2].asset), "Splash is never listed");
|
|
activate(split, maps, cb, "select-level", maps.levels[2].asset);
|
|
check(split.destination.empty(), "Splash cannot be selected");
|
|
}
|
|
std::puts("Native ReSkate control checks passed.");
|
|
return 0;
|
|
} catch (const std::exception& e) { std::fprintf(stderr, "%s\n", e.what()); return 1; }
|
|
}
|