Skip to content

Move the floor select onto the UI framework and warn of floors that limit the party - #250

Open
SirFancyBacon wants to merge 38 commits into
TheMoonPeople:masterfrom
SirFancyBacon:floor-select
Open

SirFancyBacon wants to merge 38 commits into
TheMoonPeople:masterfrom
SirFancyBacon:floor-select

Conversation

@SirFancyBacon

@SirFancyBacon SirFancyBacon commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #239: it uses the framework widgets added there (Picture, PictureNumber, MenuHand, ListCursor). Until #239 merges, the diff against master includes its commits.

image

What it does

The dungeon floor select is on dc::ui. It draws as retail's did. I compared a build before the move and the build after on one renderer, starting from a save with --town-dungeon, in 49 states, and every one matched pixel for pixel. The states covered fading in and out, moving, paging, scrolling with rows half-clipped at the board's edges, entering a floor, and every dungeon. Framework additions:

  • Picture::DrawClipped and ClippedRows: clip a picture to a band of the screen as retail's MenuTextureClip did, quirks included;
  • PictureNumber::DrawClipped;
  • Picture::DrawScreen and ScreenFill: a full-screen picture and a full-screen colour;
  • StayFrame::Draw and MenuHand::Draw overloads for a screen whose corners and hand are in its own picture.

It takes the mouse:

  • hovering puts the cursor on a floor;
  • left click goes down to it;
  • the wheel moves a floor;
  • right click is Circle.

It warns of floors that limit the party. A limited floor's row shows a red ! after its kill count. With the cursor on that floor, the window below gives the limit in the dungeon's own arrival message (dunmsd00 11, 13 and 14), so every language has the wording. The warning changes nothing in play, so it is always on. port/src/dungeon/limits.cpp knows two kinds of limit:

  • Fixed: floors whose script always limits them, read from retail's floor scripts:
    • Divine Beast Cave B7 (weapons can't be changed) and B11 (only Xiao);
    • Wise Owl Forest 4 (only Xiao) and 12 (only Goro);
    • Sun and Moon Temple 5 (only Goro) and 13 (only Ungaga).
  • Rolled: floors a new game rolls into the save (weapons locked, or faster thirst). A roll that lands on a fixed-map floor or a boss floor never applies in the game, so it isn't warned about. A fixed limit wins over a rolled one, as the script sets it last.

Floor numbers and name plates:

  • In English, the floor numbers are drawn in the caption font with the floor words they sit beside.
  • Each dungeon's name plate is now a blank plate with the name written on it, taken from the game's own system messages, so it works in every language.
    • The plate is port/lang/textures/plates/dungeon_name.png: SirFancyBacon's own art, CC0, at the disc's size. Its licence is in the README beside it, and SirFancyBacon is added to CREDITS.
    • Without the file, the painted plates stay.

Test hook --town-dungeon N: opens dungeon N's floor select once a loaded town is walking, so a save reaches the screen with its real floors, Atla and limits.

Notes

  • Text can't be clipped yet, so a row's ! disappears for the few frames its row is sliding past the board's edge, instead of being cut off.
  • res_limit_zone_id is plain char. Reading it through signed char keeps "no limit" (-1) correct on arm64, where char is unsigned.

Testing

  • New tests: DungeonLimits (3) and DungeonPlates (2).
  • Full ctest -j4 passes on Linux, apart from the five Kazarov GPU tests that fail on lavapipe.
  • Headless runs from a save: every dungeon's floor select; the warnings on Divine Beast Cave B7 and B11, Wise Owl Forest 4 and a rolled Shipwreck floor; hover, click and wheel through scripted mouse input.
  • Only English data was available. The plate names in other languages, the mouse with real hardware, and colours on a real GPU still need a check.

The vanilla item documents were made at start-up from the compiled tables.
They are now tracked in port/gameplay/items, as Dark Cloud 2's gameplay
documents are, and the build seals them into Kazarov's gameplay seed
(assets/data.zip beside dcdata). dcdata extract, or the new
publish-gameplay command on data extracted before, writes them into the
data under gameplay/.

A document is tried from the datapacks down, then the data's own, then the
compiled table, which data without the documents (from the first-run window,
or extracted before) uses. The items now load once RunGame has indexed the
data; indexing it once more beforehand cost 0.6 s per start in a debug build.

The Windows and macOS packages carry assets/ beside dcdata; the Flatpak
installs the seed there.

Tests: the tracked documents hold the tables as compiled, and the data's
own document is read under the packs.

Pins Kazarov to its game-neutral gameplay seed (gameplay_publish).
The Flatpak removed /bin/data.zip, Kazarov's seed, when Chronicle had no
gameplay documents. The seed now installs to /app/bin/assets, where dcdata
looks for it, and a fresh configure keeps it there with Kazarov's fix.
A datapack or the data can add a bag item as a document of its own,
gameplay/items/custom/<namespace>.<name>.json: its ID, retail's item
fields, prices, a retail icon and model, its text by language, and
optionally bait. Packs are read before the data, and every pack's items
load. A document that cannot be read is reported and left out.

Custom items are numbered from 0x800 in ID order, so the same items get
the same numbers every run. An item's name is message number + 100 in the
system messages, which go up to 2080, so 0x800 keeps custom names clear of
retail's. Their descriptions are at number + 500, as retail's are.

The game's own lookups answer for them: GetCommonItemInfo and GetItemData,
the shop prices, the model paths, and the item rows the dungeon reads. When
a message file lacks a custom item's name or description, the lookup gives
the item's own text in the current language, or English. The disc's own
messages always win.

Tests: custom items get numbers, rows, prices and models of their own; are
read from every pack and the data; a bad document is left out; and their
text answers the game's message lookups.
Retail tells weapons from items by number, and often takes anything from
ITEM_WEAPON_START up for a weapon. A custom item, numbered from 0x800, was
taken for one: picking it up in a dungeon read past WeaponList, and the
shop, the menus, storage and the obtained message treated it as a weapon,
or refused it as a bag item.

item::KindOf gives a number's kind: an attachment, a bag item (with every
custom item), a weapon, or none. The sites that would misclassify a custom
item ask it. Below the custom numbers it keeps retail's ranges, one-sided
weapon checks included, so retail's items behave as before. Storage keeps
retail's ITEM_DUNGEON_END for its own items.

--give takes a custom item's ID, since its number depends on the packs
loaded.

Test: a custom item has the kind of a bag item, and picked up in a dungeon
it goes into the bag.
A custom item's document may declare what each copy keeps, as names and
the numbers a new copy starts with ("state"), and the version of those
names ("state_version"). item::NewCopy makes such a copy: a permanent
instance, from a counter the save will keep, and a handle, a number of its
own from 0x1000. Retail's code carries the handle wherever it carries an
item number, so the copy keeps its identity through the bag, storage and
the menus, and copies never stack. Handles stop at 0x7E00, since a
description is looked up at the number + 500, an s16.

Everything the host answers for a custom item resolves a handle to its
item first: the item tables, the name and description, the kind, the
price and the model. A copy whose item is not registered keeps its ID and
state and finds its item again when it returns; item::RestoreCopy takes
back a copy kept in a save either way.

Between areas, where no menu holds an item, the handles of copies in none
of the bag, its quick slots or storage are freed. An item's own number in
the bag stands for a copy with the starting state. --give makes a copy of
an item with state.

Tests: copies get handles and instances of their own and answer as their
item; a freed handle is used again; a copy keeps its state while its item
is gone; a copy kept in a save comes back under a free handle; state that
is not numbers is refused.
save.dat stays retail's image: custom items and copies leave the bag,
quick slots and storage before the checksums are made, and go into
chronicle.dat, chunks kept under a hash of the save.dat they belong to
and written so an interruption leaves a matching pair. An item whose
definition is missing loads as an inert orphan that keeps its ID and
state until the definition returns.
The save and load screens show a "!" after the number of a save whose
chronicle.dat names an item that would load as an orphan.
fish.json, places.json and prizes.json hold retail's fish kinds, the
five spots' fish and spawn odds, and the exchange's prizes, read from
datapacks like the item documents. Retail's fishing code asks the
species and places registries, and each place's fish are drawn by its
spawn stage, retail's by default.
A hooked fish holds the hook for half a second to a second, where
retail let go after 21 to 50 ticks, and the moment it bites is marked
by a splash, rings at the float and a strong rumble, where retail's
tug felt much like a nibble.
The session's steps are fishing::Session, which drives the game
through an Angler interface and asks the place's stages: the bite
(what each fish does about the bait, and when the bait is lost), the
fight and the outcome, each retail's, named in places.json with
their settings.
The fishing framework keeps a list of baits, retail's thirteen and
then every custom item with the Bait trait, which the picker offers
and the hook, the fish and the give-back read. A lure comes back to
the bag after a catch and is never lost, and a species that doesn't
name a bait likes it at its default_affinity. A custom item document
names the fields it holds that this version does not read.
Every landed fish and every one that gets away is published with its
species, place and bait, and fishing's quest helpers count catches,
points, the largest and the species caught into a quest's own numbers
through the new quest::ChangeVar.
A pack adds a species with a document of its own, given a kind from
256 and a name by language that the catch message lays over its
window. Its catches are recorded in chronicle.dat's FISH chunk rather
than save.dat's records, which name retail's kinds, and are kept when
its document is gone.
The keep outcome puts a landed fish into the bag as its species'
item, each with its size where the item keeps one, and gives points
and records only where the place asks for them. A window's message
overlay now answers its inserted system messages too, where a custom
species' name goes in the catch message.
The brisk bite stage chooses one fish in the water for each cast by
its liking for the bait, which swims over in its wait, nibbles for its
liking's time and holds the hook for its window; turning or reeling in
starts the cast over. DC_FISHING_TRACE prints a session's steps and
each fish's actions.
A fight stage now begins a fight for each hooked fish. The tension
fight is Dark Cloud 2's at its own pace of 30 ticks a second: Cross
reels the line in against its tension, which builds faster than Dark
Cloud 2's, and the line snaps at the top. When the fish veers, the
stick held its way takes most of what reeling adds and winds faster,
for a moment after the arrow too; held out of position it raises
tension sharply. The fish is held at the line's end as it shortens, a
species' pull sets how hard it fights, and the port draws the tension
and line gauges.
The Rod trait carries Dark Cloud 2's rod numbers as Heft, Control,
Spring, Balance and Lure, read only by the brisk bite and the tension
fight: Heft grows the hooked fish, Control tires it, Spring eases its
pull, Lure brings fewer empty rolls and loses less bait, and Balance
scales the first three. Retail's Fishing Rod has the stats that change
nothing, and a custom item may carry a rod of its own.
The pack in port/datapacks/enhanced_fishing gives every fishing place
the brisk bite and the tension fight, each species its pull with
retail's rod, and three rods whose Spring brings the Baron Garayan
within reach.
Where the place's fight reads rods, Circle at the water opens the
port's menu: the rods in the bag to fish with, the water redrawn with
the place's odds, and the town's own menu. The rod chosen is used
while it is carried and kept with the save.
Ruty, who studies fish in Queens, asks Toan to catch every inland
fish, then a Mardan Garayan, then the Baron Garayan from her oldest
fish book, and gives him a better rod for each. Quests can now read
the game's system messages, so she names each region's towns.

Circle at the water still asks retail's "Continue fishing / Quit
fishing"; with Enhanced fishing the dialog framework draws it with
Change rod between the two, which opens the item board with only the
rods in the bag. Quit hands the town's script its answer, so each
town's own way out of fishing plays. The bait board lists only bait,
and its first row of slots no longer leaves a line of the town
showing above it.

The Enhanced pack's fish pull 0.5 to 2.0 and its rods spring 18, 33
and 80.
--jump load:<slot> and --jump autosave start in a save's town;
--town-menu opens one of its menus and --town-event raises one of its
programs, so a headless run can capture any fishing screen.
The bait board, the fish exchange and the fishing records draw with
the UI framework's widgets and retail's pictures, the same as before
pixel for pixel; the exchange and the records take the mouse. The
dialog framework asks the sign's and Circle's questions in the
scripts' own windows; with Enhanced fishing, Circle's adds Change
rod, which opens the rods' board from the event as Square opens the
bait's.
Cross on the tick a fish bites counted as an early press from a
nibble, or as reeling in from the wait; it sets the hook now.
The fishing provider took the sign's and Circle's messages wherever
the town's folder matched, a house's own scripts included, and Circle's
when not fishing; a question left taken kept every script's pad and the
menus' mouse. Providers are told whether a question is asked inside a
house, fishing takes Circle's only while fishing, a message with no
rows is left to the script, and a question is forgotten when its window
closes, an event begins or ends, or a town starts. A confirmed row is
held while the script's cursor catches up to it. The --town-menu bait
list keeps its end marker.
Nothing builds or installs the pack; port/datapacks/README.md says how
to copy it beside the data folder and name it in datapacks.json.
The build puts the packs the game ships in assets/datapacks beside the
executable, where every package carries them. A pack named in
datapacks.json that the player's datapacks folder lacks is taken from
there, and a player's own copy wins, so turning Enhanced fishing on is
naming it in that file.
A pack that changes a few items no longer has to carry a whole table. It
holds a patch beside the table, items.patch.json (kind items_patch) and so
on for every item table, listing only the rows and fields it changes. Each
pack's patch is applied over the full documents, the lowest priority's
first, so packs changing different items combine and a later pack's field
wins. A patch is checked whole as a full document is, and one that cannot be
used is reported and left out while the others apply.

native_config::ReadEach passes every pack's document at a path, where Read
stops at the first.
…rs open with their key, off by default

gameplay/items/dungeon_keys.json says which items open each dungeon's
locked room doors, floor exits and back floor entrance, as compiled from
retail's dungeon scripts, and gives each its DungeonKey trait.

gameplay/dungeon/rules.json turns two changes on (docs/DUNGEON.md); without
it, and in the tracked document, both are off and the game plays as retail:

- key_item_filter: an event's item window lists only the items its script
  takes, where retail listed the whole bag.
- auto_open_locked_doors: Cross at a locked room door, told by the map part
  that placed its event, runs the door's script as Square does and gives
  its window the dungeon's door key when the bag holds one.
gameplay/dungeon/loot.json (kind dungeon_loot) holds the tables retail
compiled into dungeonparts.cpp, by item ID: each dungeon's chest lists
for a floor or either half, its back floor's, the clown's two offers,
and how often each item is turned down. A pack's loot.patch.json
replaces a dungeon's fields or changes its chances, the lowest
priority's first, so a pack can put its own items in the boxes.

Without packs the draws are retail's, random number for random number.
Where items may lie on each map part, and how many floors a dungeon
has, stay compiled. --export-gameplay writes the document too.
Each active pack's lang/<language>.json is read over the game's own
strings, the lowest priority's first, and the save folder's over both,
so a pack brings its quests' and settings' text in every language and
can retranslate the game's. The strings are read again once the packs
load.
gameplay/quests/<id>.json (kind quest) describes a quest without code:
its stages, the rules that move it on, the choices it adds to a
villager's menu and the steps each runs (pages, questions, items,
money, a shop), its counters on the fishing log, and its English text,
translated through lang/ as quest.<id>.<name>. Every active pack's
quests are read at start-up after the items, the highest priority's
copy of each file, so a pack adds or removes a quest without the game
being rebuilt. A document that cannot work, or that names an item no
item document has, is reported and left out.

Carl's slingshot is now port/gameplay/quests/carls_slingshot.json, and
Ruty's rods moves into the Enhanced fishing pack with its translations.
Both keep their IDs, so saves keep their progress. The Mayor's supplies
stays in C++.
…at limit the party

The dungeon's floor select draws on dc::ui as retail drew it, compared
pixel for pixel from a save in every state (fading in and out, moving,
paging, scrolling with rows clipped at the board's edges, every
dungeon), and takes the mouse: a hover puts the cursor on a floor, a
click goes down to it, the wheel moves a floor. The framework gains what
it needed: pictures clipped as retail's MenuTextureClip clips them,
clipped numbers, a full-screen picture and fill, and a screen's own
corners and hand.

A floor that limits the party shows a red ! after its kill count, and
with the cursor on it the window says what the limit is in the words the
dungeon says it with on arrival. dungeon/limits.cpp knows the floors the
scripts always limit (read out of retail's floor scripts) and the ones a
new game rolls into the save, leaving out the fixed-map and boss floors
a rolled limit never reaches. The warning changes nothing in play, so it
is always on.

In English the floors' numbers are drawn in the captions' font with the
floor words. The dungeon's name plate is a blank plate (SirFancyBacon's
art, CC0) with the name written on it from the game's own messages, in
every language.

--town-dungeon N opens a dungeon's floor select from a loaded save, for
tests.
Circle on the floor select, which retail only refuses, asks "Leave
dungeon?" in the battle menu's question window, with retail's own
question (its message 300) in every language. Yes fades the select out
and the dungeon ends as leaving it does: the party goes back to the
dungeon's town, to the same place, recovered, and nothing is paid, as
no floor was entered. The mouse answers the question as it answers the
battle menu's.

The dungeon's map in its town is DungeonTownMap now, which the escape
and the floor select share.
The test seeded rand() with 1 to 400, one seed a fish. Windows' rand()
gives nearly the same first numbers for neighbouring seeds, so its 400
fish were near enough one fish and never held the hook for both 30 and
60 ticks. One seed, with the numbers running on from fish to fish over
1,000 fish, draws the hold as the game does on every platform.
…indows too

The port's rand() is the PS2's on every platform and returns 31 bits, but
the framework's spawn and bite rolls divided it by the host's RAND_MAX
plus one. Windows' RAND_MAX is 32767, so there a roll that should lie in
[0, 1) ran up to 65536: spawns came out wrong and a hooked fish held the
hook for millions of ticks. Divide by 2^31, as the rest of the port does.

This is what failed FishHook on Windows. The test goes back to a seed
for each fish: a30c10f blamed Windows' own rand(), which the port never
calls.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant