Shaman members can buy and cast their spells at the Shaman Teacher, each as in the server we port from except where noted:
Shaman's solace: a target within 10 tiles, player or creature, takes 2,200 vita, for 600 mana. A ghost needs a different cure, and nothing is spent.
Reincarnate: for 10,000 mana and a 7,500-second wait, every dead player on the caster's four sides comes back where they lie, with a tenth of their max vita and the mana they had.
Shaman's torment: at a creature, a 5 to 20 second run that strikes it every 1.5 seconds, scaled down the more creatures the caster torments on that map, and never under 1,000. At a player: "This target's soul is too pure for this magic." The run is 5 to 20 seconds of real time; in the server we port from, its creature timer stretches the same run to 7.5 to 30 seconds.
Prices: Reincarnate (level 64) 70 Acorn, 1 Amber, 500 coins; Shaman's solace (level 99, from rank 1) 1 Mountain ginseng, 10,000 coins; Shaman's torment free. Phoenix flames was already on sale.
For players: Do's spells (PR #407)
Do members can buy all nine Do spells at the Do Teacher, each at level 99 and at the price its spell script asks in the server we port from. Each casts as that script:
Dash: up to 3 tiles in the faced direction, with a 150-second wait.
Spirit blade now hits about 11% harder (x10, from x9). Balance weapon (from rank 1, x15) and Master weapon (from rank 2, x20) share the one enchant slot with it.
Blend: invisible for 2 minutes; the next landed swing does x9 and ends it. "You are no longer invisible." when it ends. The rogue's Invisible is unchanged.
Hear footstep: for 32 seconds, every 5 seconds, names each hidden player within 4 tiles.
Assess opponent: shows a creature's max vita, or a player's class, vita and mana.
Crossing swords: a six-tier climbing rage. Each tier hits harder and, from tier 2, makes the caster's armour 5 worse, so they take more damage as it climbs.
Chung Ryong's rage now checks its 120-second wait between climbs, as in the server we port from. Before, all six tiers could be climbed back to back.
Server: closing an NPC's box no longer blocks anything, teachers on the twelve PC-subpath Circles, and a one-time key for the login hand-off
2026-10-26 · Essorcal · Server PRs #402, #404, and, for operators, #403
For players: a closed NPC box no longer leaves anything waiting (PR #402)
The last notes said that after closing an NPC's box, the event cave and Forever Tree entrances and a few step encounters stayed blocked until a map change. That is fixed. After closing a box, the next NPC click, NPC speech or step works at once:
the Buya Library caverns doorway and the Forever Tree crevasse let the player through;
the fox spirit, the crypt spirit, the crow, the Watcher's rabbit and the Gauntlet statue appear as they should.
The old conversation is dropped first, so it never continues and takes or gives nothing. Before this, a late reply could still resume it: a late pick in a shop window left open still bought.
One case goes beyond the server we port from. There, the seven step-triggered places stay silent after a closed box until the player clicks or speaks to an NPC. Here a step on one of them also drops the waiting conversation. If a box is still open on screen when the player steps on one, that box's conversation ends.
A conversation the player answers (next, a pick, a typed answer) is unchanged.
For players: PC-subpath teachers (PR #404)
Each of the twelve PC-subpath Circles now has a teacher, "<Subpath> Teacher". A member of that subpath can learn and forget secrets there, with the trainers' usual lines and fees. Anyone else is told "You are not a <Subpath>, I can not help you."
The nine PC-subpath spells that work today are on sale, each at the price its spell script asks in the server we port from:
The other seven teachers say "I have no secrets to teach you yet." More spells go on sale as each subpath's spells are finished.
A full spellbook (52) pays a teacher's fee and is then refused, the same as at every class trainer and in the server we port from.
A member's Qui hyang now asks "Where do you wish to go?":
"Subpath Circle" goes to their Circle, near the teacher;
"Main Inn" goes to the same tavern as before.
Either uses one scroll; closing the box uses none. Everyone else's Qui hyang and every Yellow scroll are unchanged.
Devoting at an alignment shrine, or renouncing at the Tiger Palace summit, keeps a member's subpath spells. A spell they choose to forget stays forgotten, and changing path drops them.
For operators: the login hand-off and the database (PR #403)
Players see nothing. The hand-off from the login server to the 4.95 game port now carries a one-time key, so a replayed or stolen hand-off no longer gets in on the player's address alone. Before, an 11-letter name had nothing else behind it.
The database moves from version 4 to 5 when the new build first starts. This is one-way: a build from before this change refuses to start on the migrated database ("Database schema 5 is newer than this server supports (4)"), so going back across it needs the database restored too. Logins keep working through the deploy itself.
One edge case changes: with two logins of one 11-letter account in flight from one address, the newer one now enters instead of whichever arrived first. Exactly one enters, as before.
Server: the Dog ignores a PC-subpath member's cleanse, the Chapel's divorce and engagement costs stop wrapping, Ki strike and Ice bombardment can be deflected, closing an NPC's page takes nothing, and two settings for operators
For players: the Dog's cleanse and PC subpaths (PR #401)
A PC-subpath member (Barbarian, Chongun, Do, Merchant, Ranger, Spy, Diviner, Geomancer, Shaman, Druid, Monk or Muse) who says "cleanse" to a Dog now gets no question and loses no karma, as in the server we port from. Their path, rank and spells stay as they were. Until now the Dog of their base class asked, took 4 karma and changed nothing.
@subpath leave is still how a PC-subpath member leaves.
Base classes and NPC subpaths (Chung ryong, Baekho, Ju jak, Hyun moo) at their own Dog: unchanged.
For players: the Chapel's costs for very large pools (PR #400)
A divorce costs 2,550 experience for each point of base vita. Above 1,684,300 base vita that sum used to wrap round and charge only a few hundred experience. It now asks the full amount, as in the server we port from. That is more experience than anyone can hold, so "Yes" leads to the usual sacrifice offer (8,000 vita or 4,000 mana).
Breaking an engagement costs 1,000 experience for each point of base mana. Above 4,294,967 base mana it used to wrap the same way; it now takes all banked experience, as in the server we port from.
Below those pools, nothing changes.
For testers: Ki strike and Ice bombardment can be deflected (PR #397)
PC-subpath spells reach testers only today (with @spell); members will learn them from their subpath's teacher once that lands.
Ki strike (Do) and Ice bombardment (Geomancer) can now be deflected, as in the server we port from: by a creature, through its protection and its Will over the caster's, as every other deflectable spell here; and by a player on a PvP map, through their Will over the caster's. A caster whose Will is at least the target's is never deflected by a player.
A deflected cast still costs its mana, as every deflected spell here does, and a deflected Ki strike still starts its 7-second wait. Nothing is struck, neither the target nor anything beside it. The caster reads "Your magic has been deflected." and then "You cast Ki strike." (or "Ice bombardment").
Beyond the spells' 10-tile reach the deflect roll now comes first. A creature 11 or more tiles away can answer "Your magic has been deflected." where it used to say "Ki strike cannot reach that far.". Both cost the same.
A cast that is not deflected is unchanged. Hailstorm, which works the same way, is still never deflected, as in the server we port from.
Checked: closing an NPC's page takes nothing
The 10-24 notes said closing an NPC's dialog page might count as going on to the next page, which could cost a player who backs out of Baek's offer. A read of the 4.95 game client settles it: closing a page (the close button, Esc, walking away) sends nothing to the server. Only the next and previous arrows, a menu pick and a typed answer reply. So closing Baek's offer, or any page that comes before a price, takes nothing, as in the server we port from.
Found in the same check, not fixed yet: after a page is closed that way, the server keeps waiting for an answer that never comes. Until the player changes map or finishes another NPC conversation, the event cave and Forever Tree entrances refuse the step, and the fox, the mage-stone spirit, the crow, the Watcher and the gauntlet altar don't appear. A fix is in review.
For operators: where new characters start, a synthetic test profile, and two earlier fixes (PRs #396, #399; #382, #383)
Start point: a new setting, P1998_START_POINT=map,x,y, puts every new character on that map and tile instead of the usual start. It is empty by default, and empty changes nothing. The login server checks it at start-up against map_index.csv and refuses to start on a value it cannot use (not three numbers, an unknown map, a tile off the map). It reads the map's size once, at start-up, so give both servers the same content folder. It is meant for test content sets that have no starting town of their own.
Synthetic profile: test-content/synthetic/ is a small invented content set for client testing: two maps, walls and objects of every kind, warps both ways and one standing NPC, with a manifest. It is generated by dotnet run --project Tools -- synthetic-profile and never used unless a server is pointed at it.
Earlier fixes: behind a trusted proxy, the game port's re-login limit and IP bans now use the player's own address (PR #382), and an oversized map request is clamped to one frame (PR #383). Players on a direct connection see no change from either.
Server: the four NPC subpaths can be joined at Baek, the Watcher's rabbit appears to unblessed players of any level in Buya's north-west field, the NPC-subpath spells have a price, and testers get aligned versions of ten Sam san spells
For players: the four NPC subpaths can be joined at Baek, and the Watcher's rabbit appears at any level (PR #394)
Any player who walks Buya's north-west field without the Watcher's blessing can meet this: the Watcher's rabbit dialog appears to unblessed players of any level, about one step in 50, as in the server we port from. With Ox karma or better the rabbit "seems to smile slightly at you, then bounds away", and the player is blessed; after that it does not come back. Below Ox karma it only bounds away, nothing is set, and it can come back. Only a level-99 player can use the blessing, at Baek.
A level-99 Warrior, Rogue, Mage or Poet with Ox karma, the Watcher's blessing and the Dog linguist legend can now join their class's NPC subpath by saying its name near Baek in Kugnae: a Warrior says "chung ryong", a Rogue "baekho", a Mage "ju jak" and a Poet "hyun moo". Until now the NPC subpaths could be reached only through a GM command. Baek still buys and sells as before.
Baek's offer runs over five pages. Then Baek asks for a Mythic favor, 10 Splendid tiger pelts and 1,000,000,000 experience, and takes each part as it is checked: the favor first, then the pelts, then the experience. A player short of the pelts has already given the favor, and one short of the experience has already given the favor and the pelts. That is the order of the server we port from, and a 2001 players' guide gave the same warning: carry all three before saying the name.
With all three, the player becomes their class's NPC subpath (Chung ryong, Baekho, Ju jak or Hyun moo) and keeps their rank. They gain the legend "Attained Totem Mastery", and everyone online sees a "[SUBPATH]" line congratulating them by their new title and name.
Baek turns away, with the lines of the server we port from, a player under level 99, in a PC subpath, below Ox karma, not yet blessed, or already in a subpath. To a player without the Dog linguist legend, or one who says another class's subpath, Baek says nothing at all, as in the server we port from, and nothing tells them why.
The Dog's "cleanse" now also returns a Chung ryong, Baekho, Ju jak or Hyun moo to their base class, with their rank, as in the server we port from. As before, it takes back the Dog's spells and the NPC-subpath spells and costs karma, and its question is unchanged.
The four NPC-subpath spells now cost the price a 2001 players' guide lists, at the class trainer, instead of nothing: 50,000 coins, the subpath's key and two items each. Chung ryong's rage takes a Spike and an Electra, Baekho's cunning a Blood and a Lucky silver coin, Ju jak evocation a Surge and a Star-staff, and Hyun moo revival Purified water and a Titanium lance. As before, each is offered only to its own subpath.
Not checked: nobody has walked the field or joined at Baek on a live client yet. Closing an NPC's dialog page may count as going on to the next page rather than backing out; whether it does depends on what the game client sends when a page is closed, which has not been checked on a live client. If it does, a player who closes Baek's offer to back out could still give up whatever parts of the price they carry, since each part is taken as it is checked. This is not fixed yet; it is the first thing to be checked next.
Tests: new cases cover the field and each kind of meeting with the rabbit, a walk across the field until it appears, every refusal and shortfall at Baek with what each takes, the join for all four classes, the cleanse, the four prices and who is offered the spells; each group fails with its part of the change removed.
For testers: aligned Sam san characters are taught their versions of ten Sam san spells (PR #392)
Only testers reach Sam san, the third mark, today (with @mark). A Sam san character with an alignment (Kwi-Sin, Ming-Ken or Ohaeng) is now offered at the class trainer their alignment's versions of ten Sam san spells, 30 spells in all: the Warrior's Health's return and Mend equipment; the Rogue's Explorer's remedy, Focused Blow and Power stream; the Mage's Dooms Fire and Energy flow; and the Poet's Charge of life, Ensure and Wind master. Until now an aligned Sam san character was offered none of these ten.
Each version carries the name the live spell pages give it (Focused Blow, for one, is Burial Dance for Kwi-Sin, Flowing Energy for Ming-Ken and Freeform for Ohaeng), casts exactly as its base spell, and is learned for the same price. Wind master's three versions cannot be cast yet, as Wind master itself cannot.
Unchanged: unaligned characters and everyone below Sam san see no change, and the base spells themselves are untouched.
Tests: each of the thirty is cast beside its base spell and must match it, and what aligned and unaligned Sam san characters are offered, and at what price, is checked too; these cases fail without the change.
Server: boards and the mailbox show 20 entries at a time and load older ones as you scroll, the twelve PC subpaths can be joined, and base classes and NPC subpaths can no longer use the subpath channel
Boards and the mailbox open on their newest 20 entries and load 20 older ones as you scroll (PR #395)
Every player who opens a board or the mailbox holding more than 20 posts or letters meets this. The list opens with its newest 20. Scrolling to the bottom of the list, or moving the selection onto its last row, loads the next 20 older ones, and so on down to the first entry, as in the server we port from.
Opening a board or the mailbox, walking up to a board sign, and the list's refresh after a pop-up (after posting, for one) always start at the newest 20.
On the 4.95 client, a board or mailbox of 128 or more entries used to open empty or show only some of them (one of 300 showed 44). These lists now open normally.
Unchanged: a board or mailbox of 20 or fewer entries comes out exactly as before. No text is new or reworded.
Tests: a board and a mailbox of 300 entries are paged through to their first entry, each entry once, newest first, and those cases fail without the change; every list of 20 or fewer is checked to come out exactly as before.
Not checked: this has not been tried on a live client yet. How the 4.95 board and mail windows ask for older entries was worked out by reading the client, so which mouse actions load the next 20 (the scroll wheel at least) rests on that reading, and so do the empty lists of 128 or more, which were not seen live. The 5.33 client's request for the next 20 has never been read, so on 5.33 a longer list may show only its newest 20.
The twelve PC subpaths can be joined, and only their members can use the subpath channel (PR #393)
Every Warrior, Rogue, Mage and Poet, and every Chung ryong, Baekho, Ju jak and Hyun moo, meets this: base classes and the NPC subpaths can no longer use the subpath channel (/sp), as in the server we port from. A line sent there now gets "You cannot do that." and nobody hears it. Until now every class could use the channel to talk to its own class.
The subpath channel now carries lines only between members of the same PC subpath; any other player is refused the same way. Whispering to the name "@" now reaches the channel too, as in the server we port from, under the same rule. F2 still turns subpath chat on and off for everyone, but for anyone outside the twelve PC subpaths it never carries a line.
The twelve PC subpaths can now be joined. A GM, or an elder the GM appoints for that subpath, admits a player of its base class at level 50 or higher: a Warrior to Barbarian, Chongun or Do; a Rogue to Merchant, Ranger or Spy; a Mage to Diviner, Geomancer or Shaman; a Poet to Druid, Monk or Muse. The player keeps their rank, takes the subpath's title, badge and chat label, can wear its gear, and can use its channel.
Admission is refused below level 50, to the wrong base class, to someone already in a subpath, and to anyone still holding the Dog's secrets, which no PC subpath may keep; the Dog must take them back first.
A member can leave, and a GM or the subpath's elder can expel a member. Either way the player goes back to their base class with their rank, and loses the subpath's own spells from their spellbook.
All of this goes through the new @subpath command, now in every player's @commands and @help list. On its own it shows your standing; @subpath leave leaves your subpath; GMs and elders admit and expel with it.
Members keep learning their base class's spells at its trainer (a Barbarian at the Warrior trainer), so a member admitted at 50 can still learn those from 51 to 99. They can also still enter their base class's guild hall.
Members fight in melee as their base class: a Barbarian hits like a Warrior of the same level in the same gear, a Diviner like a Mage (with the Mage's reduced weapon damage), and so on. Members of the NPC subpaths now swing as their base class too (a Chung ryong as a Warrior); before, every subpath swung with a Peasant's class bonus. The base classes' own swings do not change.
The NPC subpaths keep their trainer menu and guild-hall door as they were; for them only the channel and the melee swing change.
Not yet: nobody teaches a PC subpath's own spells. No trainer offers them; the subpath teachers come later. Testers can still teach them with @spell.
Not checked: whether, in 2001, a subpath member also had the Warrior's extra 2 damage with a weapon, or the Mage's reduced weapon damage, has not been measured; here both follow the base class.
Tests: new cases cover joining, leaving, the channel, F2, the trainer, the guild-hall door and the melee swing; breaking each part of the change turns one of them red.
Server: long mail subjects and buff boxes no longer break their windows, experience stops at its cap instead of wrapping, Bon-Hwa offers the Il San trials, and testers get rank learn prices and eight subpath damage spells
For players: long mail subjects, buff boxes and legend lists no longer break, and experience stops at its cap (PRs #388, #391)
Mail: a letter whose subject is 254 or 255 bytes long no longer breaks the recipient's mailbox. Before, until the letter was read, its row showed broken, and so did every row below it. The row now shows the subject cut short. The same goes for the copy a sender keeps of a letter with a long subject.
Profile: with enough long buffs active at once, the buff box in the profile window used to come out garbled, and it took the legend list with it. The box is now cut short, and the legends read correctly.
Legends: a character with more than 255 legends now shows the first 255. Only a GM can give that many today.
Experience: only a player holding about 4.29 billion experience meets this. Experience now stops at its cap, 4,294,967,295, instead of wrapping round to a small number. An award that reaches the cap, including one that lands exactly on it, reads "You cannot gain any more experience until you sell some." instead of the amount gained, as in the server we port from. Selling means the shadow-stat vendors, which spend banked experience.
Unchanged: every mail subject, buff box and legend list within those limits goes out exactly as before, and below the cap every award, level-up and line is as before. No other line is added or reworded.
Tests: each of these fixes comes with new cases that fail without it.
For testers: the Il San trials at Bon-Hwa, rank learn prices and eight subpath damage spells (PRs #389, #387, #390)
Il San: a level-99 player with no rank and 160,000 base vita or 80,000 base mana now sees a third choice at Bon-Hwa, "Il San", with three trials: knowledge (give up 1,200,000,000 experience), strength (have killed the Spirit Rat at any time) and wealth (600,000 coins and 5 Well Crafted White Ambers). With all three passed, choosing Il San promotes to the first mark.
Nothing in the game sells, drops or makes those ambers yet, so nobody can finish the wealth trial, or reach Il san in play, until crafting exists.
A promotion sets the first mark and adds no stats: level, vita, mana, Might, Grace, Will, AC, gold, experience and the spellbook stay as they were. The GM @mark command no longer adds stats for a rank either, so testers see what players get.
Rank learn prices: trainers now ask the prices the live spell pages print, as the tutor tables do where they list the spell, for six Sam san spells (Dragon's harness, Blade's charm, Explorer's remedy, Power stream, Energy flow and Suppress); for Race and its three other names (Spiritual Jump, Leap of Faith and Transport), which were taught free; and for Dagger's remedy and its Kwi-Sin, Mingken and Ohaeng names, which no trainer offered. Ee san rogues can now learn Dagger's remedy in Nagnang. No ordinary player holds a rank yet, so only testers meet these prices today.
Subpath damage spells: eight damage spells of the Spy, Do, Geomancer, Shaman and Druid subpaths (Assassinate, Ki strike, Hailstorm, Ice bombardment, Phoenix flames, Earth strike, Nature's smite and Lightning storm) now cast with their own damage and mana cost, some with a wait, instead of the one 5-mana hit they all shared. Nobody holds one of these subpaths yet, so only testers reach them, by teaching them with @spell.
Assassinate and Phoenix flames can't be deflected. A deflected spell still costs its mana, as every spell does here. Ki strike, Hailstorm and Ice bombardment reach at most 10 tiles.
Tests: each of these changes comes with new cases that fail without it.
Server: a GM's mass clear of more than 255 dummies clears them all, and an over-long message-box text is cut short instead of garbled
2026-10-24 · Essorcal · Server PR #384, and, for operators, #380
A GM's mass clear of more than 255 dummies clears them all, and an over-long message-box text is cut short instead of garbled (PR #384)
Only a GM meets the first change: a mass clear of more than 255 of their own dummies at once now clears them all. Before, the ones past the 255th stayed drawn until a map reload.
An over-long text in the message box is now cut short instead of shown garbled. That covers a ban or IP-ban notice with a long reason, on the login screen or in the game; a kick notice with a long reason; and a script message line over 255 bytes.
Unchanged: every clear of 255 or fewer, and every text that already fits, which go out exactly as before; no message is added or reworded.
Tests: the cases for clears of 256, 300, 510 and 511, a 300-byte ban notice and a 300-byte script message fail without the change, and every clear of up to 255 and every text of up to 255 bytes is checked to go out exactly as before.
Operators (PR #380): the map search's fallback to the Windows client install folders can now be turned off with P1998_MAPS_INSTALL_FALLBACK=0. The default is unchanged, and players see nothing.
Server: the first hit on a sleeping or dozing creature rounds down once, like every other hit
2026-10-23 · Essorcal · Server PR #378, and, as an internal fix, #371 (how the mentorship's lines reach the mentor; nothing players see changes)
The first hit on a sleeping or dozing creature rounds down once, like every other hit (PR #378, issue #363)
Every player who hits a creature put to sleep by Sleep or Doze meets this. The first hit that lands on such a creature takes its extra damage (1.5 times under Sleep, 1.3 under Doze) and uses it up. That hit now follows the one rule every other hit has followed since PR #361 (below): everything is multiplied together, rounded down once, and only then is a hit from behind doubled. Before, the extra damage was multiplied in after the round-down and the doubling, and rounded again. This fixes the first of the two items PR #361 listed as found, not fixed.
From in front, that first hit lands at most 1 HP away from what it landed before, up or down; from behind, at most 2 HP.
In a rare case it moves by 3 HP: a big swing (200 or more before armour) from behind a dozing creature, 11 of the 784,000 swings from behind a dozing creature that were checked. In each, the old figure was already 1 HP short before the extra damage came in, and the old order carried that to 3; the new figure is the exact one. For example, a swing of 200 at a creature of AC +15, from behind while it dozes, landed 595 and now lands 598.
A trap, a creature's poison tick, a pet's swing and a harvest swing at a sleeping or dozing creature now land the same or 1 HP lower: their extra damage is rounded down, where it used to be rounded to the nearest. On a creature that is not asleep or dozing, they land exactly what they did.
Unchanged: every hit on a creature that is not asleep or dozing, from in front and from behind; Backstab and Flank's half-damage reach, which is the second item PR #361 listed and is kept as it is; Sleep and Doze themselves, their durations and their lines; and hits between players.
Tests: real swings from in front and from behind, Spark and the Mage's Bolt, and the other kinds of blow, each at a sleeping and a dozing creature. Each case with extra damage fails without the change; the three without it pass before and after.
Found, not fixed: a Rogue's sleep trap holds the creature that springs it for 38 s, but that creature takes no extra damage, where in the server we port from a trapped creature takes extra damage, as a dozing one does. A poison tick on a sleeping or dozing creature uses up its extra damage, so the next hit lands without it; in the server we port from, a poison tick does not use it up. A poison tick never takes a creature's last HP on its own, but on a sleeping or dozing creature the extra damage is added after that limit, so a tick can still kill it. All three were so before this change. None is fixed yet.
Server: a weapon proc is not cast at a player you can't attack, emotes need an ordinary player and ghosts and riders pick nothing up, the Mage's Bolt hits like Spark, Dooms Fire needs 15,275 mana, and the gap in the Buya Marketplace stall counter draws as floor
2026-10-23 · Essorcal · Server PRs #369, #370, #373, #374, #375, #376, and, as internal fixes, #372 (two rare races in Approach and Mana Steal), #377 (a protocol document) and #379 (a developer tool and an operator log line)
A weapon proc is not cast at a player you can't attack (PR #373)
Every player who swings one of these weapons at another player where PvP is off, in a town or anywhere else that is not a PvP map, meets this. A proc that rolls while you face such a player is now not cast at all, as in the server we port from, so it costs nothing and says nothing. Before, the proc reached its spell, and the spell then refused it out loud.
The Poet whip, the six Blood weapons, Flamefang, the Frost sabre, and the Cursed, Corrupted, Tainted and Forsaken blades no longer say "You can't attack that target." on about one swing in 25 at such a player, and the Forsaken staff no longer says "It doesn't work." there. This fixes the Poet whip item PR #356 (below) listed as found, not fixed.
The Giasomo stick, swung at a player you can't attack, no longer summons a Giasomo bird: it no longer spends the bird's 10 mana, and at the pet limit it no longer says "You cannot summon any more creatures right now.". Swung at a creature, or at a player you can fight, it summons as before.
A wielder short of the proc spell's mana no longer reads that spell's mana line from a proc at a player they can't attack. This holds for every one of these weapons, the Charm weapons and the Ilbon knife included. The lines were "Not enough mana to cast Freeze." (the Poet whip), "You do not have enough mana." (most of them, with "Your will is too weak." from the Charm weapons) and "Not enough mana." (the Giasomo stick). With enough mana, the Charm weapons and the Ilbon knife said nothing at such a player before, and still say nothing.
Unchanged: proc chances; every proc at a creature, including a creature that shares its tile with a player; every proc at a player on a PvP map, which is cast as before; and the GM-only Chaos armour and Shot gun, and the Viper stick.
Tests: all 22 weapon rows are swung at a faced player in a town and in the Forsaken Arena, a PvP map, until each proc has rolled three times. In the town nothing is cast, spent or shown; in the arena every roll is cast as before, and the Giasomo stick summons its bird. Two more cases keep a creature that shares a town player's tile procced, and the Chaos armour casting. The town cases fail without the change.
Found, not fixed: with Backstab or Flank, your swing also reaches a creature behind or beside you, but a weapon proc still fires only at what you face. In the server we port from, it can fire at a creature behind you (Backstab) or beside you (Flank). This was so before this change too. Not fixed yet.
Emotes need an ordinary player, and ghosts and riders pick nothing up (PR #374)
Every player who emotes or picks things up while dead, invisible, on a horse or transformed meets this. These are the checks of the server we port from, in full, and like them they say nothing.
Emotes now do nothing for a ghost, an invisible player, a player on a horse, or a player in a transformed shape (Feral, Gangrel, Rodent and Beast, the cloaks, disguises, masks and chameleons, the uniforms and the guises). Neither they nor anyone near them sees an animation, and no message appears; an invisible player stays invisible. Before, these emotes went ahead.
A ghost, or a player on a horse, who tries to pick something up (the , or < key) now gets nothing: they do not bend down, no sound plays, nothing leaves the floor, and no message appears. A rider who is also invisible stays invisible. Before, they picked things up as anyone does.
Unchanged: everyone else emotes and picks up as before; an invisible player on foot still picks up, and trying still makes them visible; a transformed player still picks up; and dropping, using, equipping, throwing and casting on horseback still read "You can't do that while riding a mount.".
Tests: 11 new cases, each sending the real emote or pick-up with another player standing by. The seven refusals fail without the change; the four ordinary cases (an emote, a pick-up, an invisible pick-up on foot and a transformed pick-up) pass before and after.
Found, not fixed: when an invisible player on foot picks something up, players outside their group see them appear but not bend down, because the bend is sent before the reveal; the server we port from reveals them first, then shows the bend. This was so before this change too. Not fixed yet.
The Mage's Bolt hits like Spark: 50 + half your level, for 20 mana (PR #370, issue #200)
Every Ming-Ken Mage who casts Bolt, Spark's Ming-Ken version, meets this. It is the ruling PR #361 (below) said was still to come for the Mage's Bolt.
Bolt now lands 50 + half your level before armour, for 20 mana, as the other seven Spark spells have since PR #361. Before, it landed 2 + 1.5x your Will for 4 mana, so a high-Will Mage's Bolt hit far harder than Spark for a fifth of the mana, and a low-Will Mage's far softer. Against a creature at AC 0: at level 9 with Will 9 it lands 54, where it landed 15; at level 50 with Will 30 it lands 75, where it landed 47; at level 99 with Will 99 it lands 99, where it landed 150. Will gear no longer raises it.
Every source has Bolt at Spark's 20 mana: the 2002 Nexus Atlas mage page, the live one, and the server we port from. None gives the old figures.
Unchanged: armour still scales the hit, as it does every spell's; the same animation and sound, no wait between casts, and the same targets; and the same refusals and lines. Bolt at nothing or at an NPC is still free and silent, whatever your mana; at a player off a PvP map it is still refused for free with "You can't attack that target."; and short of mana at a real target it still reads "Not enough mana to cast Bolt.".
Tests: the Mage's Bolt now runs through the Spark family's tests with the other seven, the live readings included, and Will 10 and Will 50 land the same 65 at level 30. 16 of these tests fail without the change.
Dooms Fire needs 15,275 mana and waits 65 s, and Siege and Focused Blow cost what the live pages say to learn (PR #375)
Only testers meet this today: all three are Sam san (rank 3) spells, which no ordinary player can reach yet. It fixes two of the three items PR #354 (below) listed as found, not fixed.
Dooms Fire below 15,275 mana is now refused for free with "You do not have enough mana.": nothing is spent or struck, and no wait starts. From 15,275 up it still takes all your mana and hits for 2.5 times it, and its wait is now 65 s. Before, it needed only 5 mana: it took the whole pool, hit for 2.5 times it (13 on 5 mana) and started a 70 s wait. The figures are the live Nexus Atlas mage page's, the only source that lists the spell.
Learning Siege, or any of its three names (Soul's Freedom, Life's End and Winter Chill), now costs 600,000 coins, a Blood and a Spike, as the live Warrior page says. Before, it cost 600,000 coins and a Sam san spike. Learning Focused Blow now costs 750,000 coins and no item, as the live Rogue page says. Before, it cost 300,000 coins and a Sam san blood. The Blood and the Spike are the plain weapons, not their ranked versions.
Unchanged: Dooms Fire's learn cost, 750,000 coins and no item, which already matched the live page.
Tests: Dooms Fire on 15,274 mana is refused, with the creature, your mana and the wait untouched; on exactly 15,275 it hits for 38,188 at AC 0, leaves you 0 mana and starts a 65 s wait. The case fails without the change.
Not in this change: the live pages also name three alignment versions each of Focused Blow and Dooms Fire, which the game does not have yet. They wait for later.
The gap in the Buya Marketplace stall counter draws as floor (PR #376)
Every player who visits the Buya Marketplace (map 4601) sees this, on the 4.95 and the 5.33 client. The one-tile gap through the stall counter now shows the same brown stall floor as the row above it, running down to the open ground below. Before, ever since the gap was opened, it showed an unrelated patch of grass and dirt in the middle of the counter.
Unchanged: walking through the gap works as before, and the warps, the counter, every object and every other tile are untouched.
Tests: one case checks the gap on the real map: walkable, drawn as the stall floor, and sent to each client as that floor. It fails without the change.
A race between a wedding and a logout is closed (PR #369)
When your fiancé answers "I do" just as you log out, your "I now pronounce you (married)" line is now handed to your connection before your logout can close it, which closes a race. No player sees a difference: in practice your game has already disconnected by then. Nobody is married or engaged differently, and no line is added or reworded.
Server: a "yes" to a proposal engages nobody if the proposer, or the player answering, is going away
A "yes" to a proposal engages nobody if the proposer, or the player answering, is going away (PR #368)
This changes only a proposal whose "yes" comes while one of the two players is going away: the proposer logging out while the other player's box is still open, or the server closing either player's connection (a GM kick, a login from somewhere else, or a client dropped for being too slow). At a "yes", before anything changes, both players are now checked for this first, ahead of the check for a player who is already committed. A "yes" refused this way engages nobody and takes nothing.
If you propose and then log out before the other player answers, their "yes" now engages nobody: they keep their ring and read the cast's own line for a player who is not online, "Player is not valid or not online.", and you keep Propose, which you can cast again once the 20-minute wait from your cast has run, as after a "no". This holds whether you stay out, come straight back in (your new login reads nothing), or the server has closed your connection.
Before, if you had come straight back in, the other player was engaged to you and their ring was taken, while you were left unengaged and still holding Propose. This fixes the item PR #358 (below) listed as found, not fixed. If you had stayed out, or the server had closed your connection, the "yes" engaged you both: you were engaged while away, told nothing, and lost Propose. For a proposer who stays out, this is a deliberate change from how PR #358 handled them, and matches the wedding, which since PR #365 (below) marries nobody when a partner has left.
If the server closes the other player's connection while their "yes" is being handled, nobody is engaged, nothing is taken, and you read "Player is not valid or not online.". Before, you were both engaged.
Unchanged: with both players there, "yes" engages you both exactly as before, after the same check for a player who is already committed, and "I must decline." reads as before.
Tests: new cases for each way one of the two can be going away at the "yes", including a logout or a server close that lands while the "yes" waits, each shown to fail without the change. Two of PR #358's @bring tests looked for the GM's "Brought" line where it never appears, so they could not fail; they now look where it does.
Found, not fixed: if the server closes either player's connection just after the "yes" has checked that both are still there, while the engagement is being written, it still engages you both, and the one whose connection closed is engaged without being told. Not fixed yet.
Server: the Spirit dragon's room on Jeweled Dragon 3 is a corridor tile players can reach, and a wedding is refused for free if the engagement has ended, or a partner has left, by the fiancé's "I do"
2026-10-22 · Essorcal · Server PRs #365, #366 (a developer tool; no change in play), #367
The Spirit dragon's room on Jeweled Dragon 3 is a corridor tile players can reach (PR #367)
Every player who hunts the Spirit dragon meets this. It fixes the room PR #357 (below) listed as found, not fixed, except in one rare case (see the end of this section).
The Spirit dragon has three rooms, one of them on Jeweled Dragon 3. That room is now the corridor tile two tiles east of the old one, which a player can walk to from the map's warp landings. Before, the room was a niche walled in on all four sides on the 4.95 map: no player could walk to the boss or stand beside it to swing, though a spell still reached it, and since PR #357 one Spirit dragon build in three stood there. The old tile is the one the server we port from uses; the new one differs from it on purpose, for the 4.95 map.
When a creature already stands on the new tile, the boss stands on one of the two open tiles beside it, which a player can also walk to. In the review, a boss standing on the new tile cut no other part of the map off.
Unchanged: the Spirit dragon's two other rooms and everything else about it.
Operators: when a room boss's build fails as a player first enters one of its room maps, the log line, and its repeat on a later entry, now names the map entered, and beside it the map the boss's row names, for example "on map 157 (a room boss whose row names map 4)". Before, it named only the row's map: for the bosses whose rows name map 4, the NPC Chamber, which nobody had entered. Every other entry-fault line is unchanged, and a boss with rooms on three maps still writes one stack trace an hour, not one per map.
Tests: three new cases, each shown to fail without its fix. One walks Jeweled Dragon 3 from its warp landings, reaches the new tile and the two beside it but not the old one, and builds the boss 60 times: every build on this map stands on the new tile.
Found, not fixed: if creatures stand on the new tile and on both tiles beside it at the moment the boss is built, it lands on a walled-in tile by the old room, where no player can walk to it. With the map's 25 Mighty wyrms spread evenly, that is about one build in 4,000 of those on this map. Not fixed yet.
A wedding is refused for free if the engagement has ended, or a partner has left, by the fiancé's "I do" (PR #365)
This changes only a wedding whose "I do" question is still open when the engagement ends or one of the two players leaves. When the fiancé answers "I do", the wedding now checks again, before anything changes, that the engagement still holds on both sides and that both players are still there. A refused wedding costs nothing and marries nobody, and the Chapel opens no box.
If the engagement has ended by the time of the "I do", for example because you started the marriage ceremony and then broke off the engagement at the Chapel while your fiancé's "I do" question was still open, their "I do" now marries nobody: you read "Your engagement to X has already ended.", and so does your fiancé, each naming the other. The break-off itself is unchanged: you pay for it and read "It is done.", and your fiancé reads "Your engagement to X has ended.". Before, their "I do" still married you both, with "I now pronounce you (married)" to each of you and the "Congratulations! You are both now married." box for you. This fixes the first item PR #355 (below) listed as found, not fixed.
If either of you is going away when the "I do" comes, logging out or with a connection the server has closed (a GM kick or ban, a login from somewhere else, or a client dropped for being too slow), nobody is married and the one still there reads "Both parties must be present for the ceremony to commence" as a status line. If you log out and straight back in, your fiancé reads the line and you read nothing. Before, the wedding went ahead: if you had logged out and stayed out, or the server had closed your connection, you were married without being told; if you had come straight back, your fiancé was married to you while you were still only engaged; and if your fiancé was the one leaving, you were both married.
Unchanged: with both of you there and the engagement still holding, "I do" marries you both exactly as before, and "I don't." reads as before.
Tests: new cases for each way the engagement can stop holding and each way one of you can leave while the "I do" waits, each shown to fail without the change, and two races of 64 rounds each, the "I do" against the break-off and against a logout, in which 36 and 35 rounds went wrong before the change and none with it. A wedding whose engagement holds, and "I don't.", send exactly what they sent before.
Server: @bring, Resurrect and @rez never move, revive or heal a player who is logging out, the Leviathan's "Sworn enemy" brand is never lost to a race, and Propose is kept until a "yes", with a 20-minute wait from every cast
2026-10-22 · Essorcal · Server PRs #358, #362 (a protocol document for client builders; no change in play)
@bring, Resurrect and @rez never move, revive or heal a player who is logging out, the Leviathan's "Sworn enemy" brand is never lost to a race, and Propose is kept until a "yes", with a 20-minute wait from every cast (PR #358, issue #330)
Every player who proposes, or is proposed to, meets the Propose changes. The rest come into play only in a race: a player who logs out at the very moment a GM's @bring or @rez, or another player's Resurrect, reaches them; or a legend mark being written to you just as you kill a leviathan.
A GM's @bring on a player who is logging out at that moment now reads "'X' isn't online." and moves nobody. Before, it could still put the departed player back on a map, where they could be found by name and their character kept being saved.
A Resurrect, any of the Poet's Resurrect spells, cast at a ghost who is logging out at that moment now revives and moves nobody: the ghost stays dead, in the game and in their saved character. The caster still pays its mana and its cooldown, as before. Before, the ghost could be put back on a map and revived, and saved revived.
A GM's @rez by name, on a ghost who is logging out at that moment, now heals and saves nobody and reads "'X' isn't online.". Before, it read "Restored X to full health." and saved the departed ghost's character revived.
Killing a leviathan while its quest is open brands you "Sworn enemy of the Leviathans", with the line "You promised to not attack us! You are now a sworn enemy of the Leviathan.". The brand and its line are no longer lost when a legend mark is being written to you at the same moment. Before, the check behind the brand tripped over that write and was dropped, so you got neither. This was rare.
Propose is now given up only at a "yes". After a "no", a closed box, the other player's logout or warp, or no answer at all, you keep the spell; when they log out or warp you are still told nothing, as before. Before, the cast itself took Propose, so anything but a "yes" cost you the spell, and getting it back took a new ring: 4,000 gold, then a 24-hour wait.
Propose now has a 20-minute wait, started by every cast: a cast inside it reads "Propose isn't ready yet." and asks nobody. The wait starts at the cast, before you type a name, so a name you never type, or one that is refused, still starts it, and it is kept across a relog. The script of the server we port from intends this wait, but a slip in that script means that server never applies it; Project1998 applies it, so that a kept Propose cannot be cast again at once after a "no", or at two players at the same moment. A death and a relog together, in either order, can end it early.
At a "yes", both players are now checked again. If either is engaged or married by then, the "yes" is refused for free: no engagement, no ring taken, and the proposer keeps Propose. Each player reads the cast's own line from their side: "You are already committed to someone else!" for the one who is, and "X is already committed to someone else!" for the other. Two "yes"es to one proposer are now decided one after the other, so only the first engages them. Before, the "yes" engaged them anyway, and a player could be left engaged to a proposer who was engaged to someone else.
Unchanged: Summon, which already fizzled for free when the person logged out before being moved (PR #325, below); doors, warps, @warp, Gateway and Return, which you take yourself, so your own logout can only come after them; the NPC Rebirth and the Shaman's revival; and a "yes" between two players who are both free, which engages them as before.
Tests: each race is forced through the real logout or a real legend write, and the Propose cases cover every answer, the wait after a cast and across a relog, a committed player at the "yes", and two "yes"es released together for 40 rounds. Each guard is shown to fail without its fix.
Found, not fixed: a proposer who relogs while the other player's box is still open, and is then answered "yes", is left unengaged and still holding Propose, while the other player is engaged to them and that player's ring is taken. The half-engagement was already possible before this change; the kept spell is new. Neither is fixed yet.
Server: 60 bosses wait in their own rooms, the Spark spells land 50 + half your level, every hit rounds down once, a Harden Body ward blocks venom ticks, and Bolt and Freeze at nothing are free and silent on a PvP map too
2026-10-22 · Essorcal · Server PRs #357, #359, #361
60 bosses whose spawn points sat in the hidden NPC Chamber now wait in their own rooms (PR #357, issue #333)
Every player sees this, on the stock 4.95 client too. 60 bosses had their spawn points in the NPC Chamber (map 4), a 12x12 room no player can reach, and were sent from there to rooms on other maps. So none of them appeared until someone, in practice a GM, entered the Chamber; a killed one came back only while someone stood there; and an @reload took them away again.
They are the Mythic, Divine and Spirit bosses and the avenger of each of ten animals (monkey, sheep, ox, boar or pig, horse, dog, dragon, snake, rooster and rat), and 20 more, such as the Monkey mauler, the Dragon slayer and the Rat lord.
Each now waits in its own rooms. After a start-up or an @reload, a boss is built the first time anyone enters any of its room maps, in one of its rooms picked at random, so a boss with rooms on two or three maps may stand on one you did not enter. 20 bosses have one room map, 21 have two and 19 have three: 119 maps, none shared. Entering the NPC Chamber no longer builds or refills them.
A killed boss comes back after its respawn time, 360 s for all 60, as soon as someone is on any of its room maps. While nobody is on any of them it stays away, however long that is, and entering or hopping between its maps does not bring it back sooner. After an @reload it is rebuilt at once if someone is on one of its room maps, and otherwise waits for the next first entry.
A boss stands on its room's tile, or on the nearest open tile within 2 when that tile is a wall or taken. 46 of the 119 room tiles cannot be stood on: 44 are walls, and the Divine sheep's and the Sheep avenger's rooms on Woolen Squeeze 2 and 3 are each one column outside the map. There the boss stands a tile or two away.
The Monkey avenger skips one of its three rooms: the one on Monkey Dance 3 lies outside that map, with no tile of the map within 2 of it, so a boss built there could never have been reached or killed. It waits in its rooms on Monkey Flow 3 and Monkey Wasteland 3.
Unchanged: Gim Yi's Den keeps its one Gim Yi; the Chamber's three nine-tailed foxes stay there; every other spawn point, spawn group and NPC; and every boss's stats, drops and behaviour.
Tests: eight new cases, each shown to fail without its part of the fix. In the review, a test bot that entered Gnaw 1 met the Mythic rat near its room with this change, and not without it.
Found, not fixed: the Spirit dragon's room on Jeweled Dragon 3 is a sealed niche on the 4.95 map, so the boss stands where no player can walk to it or face it to swing; a spell still reaches it. It stood there before too, whenever a GM had entered the Chamber; this change makes it the usual sight. The room is being moved to the corridor two tiles away.
The Spark spells land 50 + half your level, every hit rounds down once, a Harden Body ward blocks venom ticks, and Bolt and Freeze at nothing are free and silent on a PvP map too (PR #361, issues #78, #82 and #200)
Every player sees this; only Bolt aimed at an id that finds nobody needs a client that sends a target id. The damage changes are small: most hits do not change, and most of those that do move by 1 HP, a few by 2, up or down. Ordinary hits from behind, and your ordinary swings at a creature, land exactly as before (Backstab and Flank aside: see the end of this section).
Spark, Glimpse of the Void and Nature's Ire for Mages, and Spark, Glimpse of the Void, Bolt and Nature's Ire for Poets, now land 50 + half your level before armour, which matches 19 readings from the live game, where a +1 Will item changed nothing. Before, they landed 50 + a quarter of your level plus your Will. With Will equal to your level, a cast changes by 0 or 1 HP, up only and at odd levels only: at level 15 against AC +50 it lands 86, where it landed 85. With Will away from your level it moves by a quarter of the gap: at level 50 with Will 30 it lands 75, where it landed 70, and with Will 70 it lands 75, where it landed 80. Will gear no longer raises these seven spells.
The Mage's Bolt is not one of them: it keeps its own formula, 2 + 1.5x your Will for 4 mana, which is to be ruled on separately. Only the one rounding below applies to it.
Every hit now multiplies armour, the extra damage a sleeping target takes and the cut from Sanctuary or Cunning together, rounds down once, to at least 1, and only then doubles a hit from behind, in the order the server we port from uses. Before, each step rounded on its own, and a creature's swing and a player's spell took armour and the sleep bonus in opposite orders, so the same 101 at AC -50 on a sleeping target landed 75 from a creature and 76 from a player. Both land 75 now.
A weak hit no longer rounds to 0: a creature's 9 on a player of AC -80 under Sanctuary did nothing before, and now does 1.
Up to 3 HP, rarely: a hit from behind on a target that is asleep or under Sanctuary or Cunning (about 1 in 800 of the creature-swing combinations checked), and the Mage's Bolt at a player, whose half point used to be rounded first (37 of 830,790 combinations; for example Will 13 against a player of AC +100 under Doze lands 55, where it landed 58).
A Harden Body ward, from the Poet spell, its three alignment versions or the Scroll of Immortality, now blocks every venom tick's HP loss. The venom's timer, its flash and "Poison is spreading through your veins." carry on, but a warded tick takes no HP, shows no HP bar, does not wake you and does not mark the venom's caster as your PvP foe. Before, a warded player on 1,000 HP fell to 700 from a 300 tick and woke up, and one on 5 HP fell to 1 from a 10 tick; now they stay on 1,000 and on 5. Against a divine boss's venom (450 every 1.5 s for 20 s, 5,850 in all), Harden Body's 12 s put up at once now lets through about 2,250. Keeping a warded player on 5 HP differs on purpose from the server we port from, which drops them to 1.
On a PvP map, Bolt and Freeze cast at nothing are now free and silent, as they already were off a PvP map and as they are in the server we port from. With nothing faced they no longer land on you; before, they said "You cast Bolt." (or "You cast Freeze."), took 4 mana (Freeze 800), played the pose and struck you. Bolt aimed at an id that finds nobody no longer says "You cast Bolt." and takes 4 mana for no blow. With the next bullet, this fixes the three Bolt and Freeze items PR #356 (below) left as found, not fixed.
Bolt or Freeze cast at nothing with too little mana now says nothing, on any map. Before, it said "Not enough mana to cast Bolt." (or Freeze). It cost nothing then either.
Unchanged: Bolt and Freeze at a creature or a player with too little mana still say "Not enough mana to cast Bolt." (or Freeze); Bolt aimed at your own id on a PvP map still strikes you; Spark and the other damage spells built like it, cast at nothing on a PvP map, still land on you; a venom tick with no ward; and creature spells, which still ignore armour.
Tests: new cases for the seven Spark spells against the live readings, real swings from in front and from behind, every ward against a venom tick, and Bolt and Freeze at nothing; each change's tests are shown to fail without it. Run against the old code, the tests for hits from behind land the same figures, which shows that ordinary hits from behind are unchanged.
Found, not fixed: two kinds of hit still round in two steps: a spell or swing at a sleeping creature, and a Backstab or Flank hit on a creature facing away, which with this change lands 1 HP lower about half the time (rarely 1 higher). Both are tracked in issue #363. Also known, and left as it is: the server's arithmetic now and then lands 1 HP under the exact figure, as it already did for armour (for example 25 at AC 16 lands 28, not 29).
A database fault at arrival or in a ban, mute or IP-ban read is logged as an error (PR #359)
Operators: a database fault while the game door checks an arriving player's login ticket, or while reading a ban, a mute, an IP ban or the @bans list, now writes one Error line with the exception, the account (the address for an IP ban, the rows read so far for @bans) and the answer given. Before, nothing logged the fault itself, and a ticket fault looked like a stale ticket. Every answer is unchanged: a failed ban, mute or IP-ban read still counts as none, @bans still lists the rows read before the fault, and the arrival still writes its "invalid/expired handoff token" line, now after the Error line.
Server: Bolt and Freeze charge only for a real target off a PvP map, curses refuse an NPC, Chapel answers after your partner ended the vow read that it ended, the Warrior reskins hold the 1 s cast slot, and the Inferno spells, Wind master, Siege and Focused Blow follow their sources
2026-10-22 · Essorcal · Server PRs #354, #355, #356
Bolt and Freeze charge only for a real target off a PvP map, curses refuse an NPC, and a curse or a hold keeps the target it found first (PR #356)
Every player sees this, on the stock 4.95 client too, except the parts that say they need a client that sends a target id. It fixes the Bolt, Freeze and curse problems PR #351 (below) left as found, not fixed, except on a PvP map (see the end of this section). Bolt here is the Mage's Bolt; the Poet's spell of the same name is built differently and does not change.
Off a PvP map, Bolt cast with nothing faced now says nothing and spends nothing: no line, no mana, no cast pose. Before, it said "You cast Bolt.", took 4 mana and played the pose, and the blow struck nothing. Freeze cast with nothing faced is refused the same way; before, it said "You cast Freeze." and took 800. The stock client meets Freeze only through the Poet whip, which procs only at something you face. A client that sends a target id gets the same answer when it aims at an id that finds nobody, or at a player on another map.
Off a PvP map, Bolt or Freeze at a player, whether you face them or aim at them by id, or aimed at your own id, now says only "You can't attack that target." and spends nothing: no mana, no pose. Before, that line was followed by "You cast Bolt." (or "You cast Freeze."), 4 mana (Freeze 800) and the pose. The line is the one the blow's PvP check has always sent, and the player is untouched.
The Poet whip's Freeze proc (4% of swings) at a player you face in a town: the swing itself is as always, and the proc now shows "You can't attack that target." and costs nothing. Before, each proc took 800 mana with the same line.
A curse at an NPC now says nothing and spends nothing, on any map: no line, no mana, no pose, no animation, and the NPC carries nothing. That covers all 22 spells that work as curses: the 17 in the spellbook (Pestilence, Vex, Scourge, Dishearten and the rest) and five weapon procs, the Cursed blade's Curse, the Frost sabre's Chill, the Corrupted blade's Corrupt, the Tainted blade's Taint and the Forsaken blade's Forsake. A client that sends a target id gets the same at the NPC's id.
Before, a curse from the spellbook at an NPC said "You cast X.", took its mana (20 to 2,000), played the pose and showed the curse's animation over the NPC, which then carried the curse. When the roll was lost it said "Something went wrong." and "You cast X." for the same mana, and cursed nothing. A second cast at the cursed NPC said "You already cast that spell.". A curse proc took its mana (40 to 500) and cursed the NPC, or lost the roll and said "Something went wrong." for the mana, and each later proc said "You already cast that spell.".
A curse or a hold, such as Doze, now finds its target once, at its first step, and every later step uses that answer. Before, each step looked again, so in the few microseconds between two of them the target could change. Now a player you aim at by id (a client that sends a target id) who leaves your map mid-cast is nobody, not the player you face, whose protection was never checked. A creature you face that steps aside keeps the curse or Doze's 1.3x, and a player who steps onto its tile takes nothing. A target that has gone, a player who left your map or logged off, or a creature that died or was taken off the map alive, is nobody: a hold then refuses for nothing, the 1.3x is armed on no one, and a curse whose target goes after its roll has already paid and lands on no one, which is what such a curse has always cost. This fixes the double pick PR #343 and PR #348 (below) left as found, not fixed.
Unchanged: with too little mana, Bolt still says "Not enough mana to cast Bolt." first and costs nothing, at nothing and at a player, and a curse still says "You do not have enough mana."; on a PvP map, Bolt and Freeze cast with nothing faced still land on you for their mana, and aimed at an id that finds nobody they still cost their mana and strike nothing; Bolt and Freeze at a creature, and at a player on a PvP map; their damage; the NPC Ambush and the 17 other spells like it; the rogue's Ambush, which still leaps behind an NPC, now by decision, as it does in the server we port from; and what PRs #349 and #351 changed at an NPC.
Tests: 51 cases, each shown to fail without its fix, and controls that show a curse still lands on a creature, a player on a PvP map is still struck, a cast at nothing on a PvP map costs what it did, and the rogue's Ambush still leaps behind an NPC.
Found, not fixed: on a PvP map, Bolt and Freeze cast at nothing still charge: with nothing faced they land on you for their mana, and aimed at an id that finds nobody they cost their mana and strike nothing, where the server we port from is free and silent in both. Bolt cast at nothing with too little mana still says "Not enough mana to cast Bolt.". All three are being changed to free and silent. Off a PvP map, the NPC Ambush still charges 5 mana at a player ("You can't attack that target." then "You cast Ambush."), and the Poet whip in a town still procs at a player you face about one swing in 25, showing "You can't attack that target.", now free, where the server we port from never procs it there. These wait for the rulings on the NPC Ambush and the 17 spells like it.
A Chapel answer given after your partner ended the vow reads that it ended (PR #355)
This changes only what you read when your partner ends the vow at a Chapel while your own Chapel dialog waits on you. Every answer here was free before and is free now, and nothing else about either of you changes. It fixes the stale lines PR #350 (below) left as found, not fixed.
A "No" to Break Off Engagement's "Are you sure you want to end the engagement?" now reads "Your engagement to X has already ended.". Before, it read "I hope you can salvage your relationship.". A "No" to Divorce's "It will cost N experience. Are you sure you want this divorce?" now reads "Your marriage to X has already ended.". Before, it read "Patience and love will save your marriage. Divorce is not something to take lightly.".
"Marriage", picked from Crystal's menu as it was offered while you were engaged, now reads "Your engagement to X has already ended.". Before, it read "The proposee should start the marriage ceremony.".
At the ceremony's own question, "Are you certain you wish to devote yourself to this man or woman for life?", both "Yes" and "No" now read "Your engagement to X has already ended.". Before, "Yes" read "Both parties must be present for the ceremony to commence" and "No" read "Come back when you are firm in your resolve to marry.".
Unchanged: a late "Yes" to Break Off Engagement or Divorce, which already read the "already ended" answer for free (PR #350); and every answer from a player whose vow still holds, which reads exactly what it read before, including "Both parties must be present for the ceremony to commence" when the fiancé is offline.
Tests: 13 new cases. The five late answers fail without the change; six pin what a player whose vow still holds reads at the same answers, the same before and after the change; and two would catch a change that could leave two partners answering at the Chapel at once waiting on each other for good.
Found, not fixed: if you start the marriage ceremony and then break off the engagement at the Chapel while your fiancé's "I do" question is still open, you pay for the break-off, and their "I do" still marries you both. This is being fixed. A partner whose game has gone before the server sees the connection drop is still ended at a Chapel visit and never told, as PR #350 (below) noted; that is left as it is.
Six spell rulings: the Warrior reskins hold the 1 s cast slot, the Inferno spells need 600 mana, Wind master is refused, the Rogue's Wolf's Fury twins come at level 30, Spirit Barrier is now Spirit Body, and Siege and Focused Blow follow the Nexus Atlas (PR #354)
Ordinary players reach three of these: the Warrior reskins, the Rogue's Wolf's Fury twins and Spirit Body need only an alignment, which any player can take at level 50 or above at one of the three alignment shrines. The Inferno spells, Wind master, Siege and Focused Blow need a rank that today only a GM or tester command can set, because no rank-promotion NPC is ported yet.
No Fear, Tiger's Pounce and Wind's Blast, which are Berserk under the three alignments, and Death's Angel, Nature's Own and Bladedance, which are Whirlwind, now hold the shared cast/swing slot for 1 s, as Berserk and Whirlwind do. Before, they had no cast delay, so a swing or another strike could follow at once. Their mana, damage, vita cost and wait are unchanged. PR #322 (2026-10-05, below) left this as found, not fixed.
Inferno, Death's Door, Nature's Denial and Steel Storm now refuse a cast below 600 mana: "You do not have enough mana.", with nothing spent and no 70 s wait. As with any refused spell that has a cast delay, your next cast or swing still waits its 1 s. Before, they fired from 5 mana up, emptied your mana and started the 70 s wait. From 600 up nothing changes: the cast takes all your mana and waits 70 s.
Wind master is refused silently and for free until a source names the creature it summons: no line, no pose, no mana, no wait, nothing summoned. Before, a cast took 10 mana, said "You cast Wind master.", played the pose, started an 8-minute wait and summoned nothing. It can still be learned as before. PR #317 (2026-10-05, below) left this as found, not fixed.
The Rogue trainer now lists Soul's Rage, Spirit of the Forest and Augmentation at level 30, as it lists Wolf's Fury, not at 34. Their cost is unchanged. An alignment needs level 50, so an aligned Rogue could already learn them: what changes is the level they are listed at, and so their place in the trainer's list, which is ordered by level.
Barrier's Kwi-Sin version, a Poet spell, is now named "Spirit Body", the name the 2002 Nexus Atlas gives it, in the spell book and in "You cast Spirit Body."; it was "Spirit Barrier". Only the name changes, and a spell book that already holds it keeps it.
Siege and Focused Blow now hit, cost and wait what the Nexus Atlas pages say (issue #328). Focused Blow, a Rogue strike, costs 3,000 mana, hits for 2x your current vita at 0 AC, leaves you a third of your vita (rounded up) and waits 38 s. The Warrior's Siege and its three alignment names, Soul's Freedom, Life's End and Winter Chill, cost 8,000 mana, hit for 1.875x your current vita plus 0.5x your mana (as it was before the 8,000 is taken) at 0 AC, leave you a quarter of your vita (rounded up) and wait 43 s. Before, all five cost 60 mana and a 60 s wait, hit for 1 and cost no vita.
Neither can kill you: each leaves you a share of your vita, so at 1 vita you stay on 1. With less mana than the cost, each is refused for free, where it used to fire for 60. At nothing or at an NPC they still strike nothing and cost no vita, now for their new mana and wait.
Unchanged: every other spell's cast delay, and every other spell the review cast before and after the change, Berserk, Whirlwind, Lethal Strike, Desperate Attack, Dooms Fire, Assault, Heal and Wind dancer among them.
Tests: each of the six rulings is pinned by tests shown to fail without it.
Found, not fixed: the live Nexus Atlas gives Dooms Fire a mana cost of 15,275, while here it still casts from 5 mana, the same gap this PR closes for Inferno. The live pages' learn costs for Focused Blow (750,000 coins) and Siege (a blood and a spike) differ from ours (300,000 coins and a blood; the spike only). The live Rogue page names three alignment versions of Focused Blow, Burial dance, Flowing energy and Freeform, that the game does not have. Each is left for a decision.
Server: Bolt, Venom and the other damage spells at an NPC are refused for free, a sacrifice strike at one costs what a strike at nothing costs, a Chapel confirm for a vow already ended is free, and an online partner reads that their vow ended
A Chapel confirm for a vow your partner already ended is free, and an online partner reads that the vow ended (PR #350)
Every player sees this, on the stock 4.95 client too: it runs through the Chapel's own dialog, at Crystal or any other Chapel NPC, and through the status line. It fixes the two Chapel problems PR #344 (below) left as found, not fixed. The new answer and line are checked through the real packets in the tests, but have not yet been seen on a live client.
If your partner ends the vow at a Chapel while your own Chapel dialog waits on you, at its menu or at any of its questions, your "Yes" to Break Off Engagement or Divorce is now free, and the Chapel says "Your engagement to X has already ended." or "Your marriage to X has already ended.": PR #344's login line, with "already". Before, a break-off still took 1,000 experience per point of max mana (or all you had, if less) and said "It is done.", and a divorce took 2,550 experience per point of max HP and said "You are now divorced.".
With too little experience for a divorce, that answer now comes at once and no sacrifice is offered. Before, the Chapel said "Hmmm.. you don't have the experience to divorce, but there is something else you can offer.", asked the sacrifice's two questions, then took 8,000 base Vita or 4,000 base Mana and said "You are now divorced.". If your partner divorces while the sacrifice's questions wait, your "Yes, do it" is free too and gets the same answer, where it took the same price.
An online partner whose side of the vow your Chapel visit ends now reads the status line "Your marriage to X has ended." or "Your engagement to X has ended.", the line PR #344 shows at login, after the client's own "Love removed." for a marriage. Before, they saw only "Love removed." for a marriage, and nothing at all for an engagement. That includes a player whose own Chapel dialog is waiting: they read the line once, and the answer above at their "Yes".
A partner the visit finds online who is logging out at that moment, or whose connection the server has already closed (a GM kick or ban, a client dropped for being too slow, or a failed send), now keeps their side of the vow until their next login, which ends it for free with the line, once. Before, the visit ended it anyway and the line was lost, so they were never told, not even at their next login.
The same holds at login: if the server closes your connection while you arrive, a vow your partner no longer holds is now kept until your next login, which ends it for free with the line, once. Before, that login ended it and the line was lost, and the next login had nothing to tell you.
Unchanged: a confirm for a vow still in place, at the same price and with the same lines; both partners answering at once, each ending their own side before the other's reaches it, where both pay, as before; "No", "I'd rather not" and "No, nevermind", free and as before, even when the vow already ended; an offline partner, who reads nothing at the visit and the line once at their next login (PR #344); a partner whose vow names someone else, or nobody, who reads nothing; and the login line's words and moment.
Tests: 32 new cases through real logins and real Chapel dialogs, and PR #344's two races (both partners at the Chapel at once for 100 rounds, and a login racing the partner's visit for 60) now also check who pays and who reads the line. 22 of the new cases failed before their fix, and so did both races: the late confirms, the online partner, the partner logging out and the partner whose connection the server closed. Six more guard against a line sent where nothing ended, or sent twice, and four pin that the line is sent under the partner's own lock.
Found, not fixed: the Chapel's "Marriage" entry goes stale the same way. Offered while you were engaged and picked after your fiancé broke it off, it says "The proposee should start the marriage ceremony.", free. A "No" after the vow already ended still says "Patience and love will save your marriage." or "I hope you can salvage your relationship.", free. A partner whose game has gone while the server has not yet seen the connection drop (no reset yet, or a half-open link) is still ended at the visit and never told, and so is one whose connection the server closes just after the Chapel checks it.
Bolt, Venom and the other damage spells at an NPC are refused for free, and a sacrifice strike at one costs what a strike at nothing costs (PR #351)
Every player sees this, on the stock 4.95 client too. The stock client reaches it with a spell from the spellbook while you face an NPC, and with three weapon procs on a swing while you face one: the Poet whip's Freeze, the six Blood weapons' Venom and Flamefang's Burn. A blow cannot hurt an NPC, so before, these casts were charged for nothing.
Bolt with an NPC faced now says nothing and spends nothing: no mana, no cast pose. Before, it said "You cast Bolt.", took 4 mana and played the pose, and nothing was drawn. Freeze cast from a spellbook that was given it is refused the same way; before, it said "You cast Freeze." and took 800 mana.
The Poet whip's Freeze proc (4% a swing) with an NPC faced: the swing itself is as always, and nothing else happens. Before, each proc took 800 mana (a proc shows no line or pose).
The NPC Ambush and the 17 other damage spells that work like it now say nothing and spend nothing at an NPC. Before, each said "You cast X.", took 5 mana, played the pose and showed its zap over the NPC.
Call Lightning and the 73 other Damage spells built like it now say nothing and spend nothing at an NPC. Before, each said "You cast X.", took its mana (1 to 600) and played the pose. Some took more, each with its aether: Hellfire, Consume Soul, Flesh Eaters and Hurricane 36,000 of a 50,000 pool; Dooms Fire, Retribution, Palm of Life, Spirit Puppet and Tornado the whole pool; Assault, Cold Snap, Death's Challenge and Volley 5,000 mana, a shout and half your HP; Slash a tenth of your HP and Feral Berserk two thirds.
Venom, Spirit's Leech, Snake Bite and Corruption with an NPC faced now say "It doesn't work.", as Venom already says when no creature is faced, and spend nothing: no mana, no pose, no venom. Before, each said "You cast X.", took 60 mana, played the pose and venomed the NPC, with the spell's animation repeating over it every 1.5 s for the whole window, though no tick could ever land. A second cast said "You already cast that spell.".
The Blood weapons' Venom proc and Flamefang's Burn proc (4% a swing) with an NPC faced now say "It doesn't work." on each proc and cost nothing, as the Charm weapons' and the Forsaken staff's procs already answer an NPC. Before, the first proc took 60 mana (Burn 200) and venomed the NPC, and each later proc said "You already cast that spell.". The same Venom and Burn cast from a spellbook that was given them get the same answer; before, they said "You cast X.", took 60 or 200 mana, played the pose and venomed the NPC.
A sacrifice strike with an NPC faced (Whirlwind, Berserk, Lethal Strike, Desperate Attack, Siege, Focused Blow and the 15 others built like them) now costs exactly what the same strike at nothing costs: "You cast X.", 60 or 120 mana (Desperate Attack 60), the aether, the swing and, but for Siege and Focused Blow, the shout. It strikes nothing: no HP cost, nothing drawn over the NPC, no HP bar. Before, it also drew the strike and an HP bar over the NPC, Desperate Attack took all your mana, and all but Siege and Focused Blow took the strike's HP cost (a half, two thirds, or all but 10 HP: Whirlwind left you on 10).
With too little mana and an NPC faced, Bolt, Freeze, the NPC Ambush and the spells like it now say nothing, because the NPC is refused before the mana is asked. Before, they said "Not enough mana to cast X.". Nothing was ever spent. The stock client meets this through the Poet whip below 800 mana: each proc at an NPC used to print that line, and is now a plain swing. At a creature it still prints it. Every other spell, and a strike still on its aether, keeps its line.
A client that sends a target id gets the same answers when it aims these casts at the NPC's id, or at the id of a player on another map while facing an NPC.
Unchanged: every one of these spells at a creature, at a player and at nothing, whether faced or aimed by id; each proc at a creature; Drain and Kamikaze; and the area spells, such as Erupt and Fissure, which skip an NPC, strike the creatures around it and cost what they cost at nothing. A spell with a cast delay, such as Bolt, still starts that delay before it is refused, so your next cast or swing waits as before.
Tests: 110 new cases, each a real cast or real swings. The 58 cases at an NPC fail without the fix, and the controls show that the same casts and procs still land on a creature, that a cast at nothing costs what it did, that the area spells still skip the NPC for the creature beside it, and that the rogue's Ambush still leaps behind an NPC. At a later era date than the shipped one, a strike at an NPC is pinned to strike nothing as well.
Found, not fixed: off a PvP map, Bolt and Freeze still charge for a cast at nothing ("You cast Bolt." for 4 mana), and on any map when aimed by id at a player on another map with nothing faced. Off a PvP map, Bolt, Freeze, the NPC Ambush and the spells like it also still charge for a cast at a player: "You can't attack that target." then "You cast Bolt.", for 4. By the same code, the Poet whip's Freeze proc at a player you face in town costs 800 a proc. Curses at an NPC still charge and leave a curse on it, the Chill, Corrupt, Curse, Taint and Forsake procs included (40 to 500 mana). The rogue's Ambush still leaps behind an NPC for no mana, left for a decision of its own.
Server: Doze, Sleep, Blind and the other holds refuse for free before they roll, Drain and Kamikaze strike the victim they read and refuse an NPC, a vow your partner no longer holds ends at login, and actions on a busy player wait for them
Drain and Kamikaze at an NPC are refused for free (PR #349)
Every player sees this, on the stock 4.95 client too. The stock client reaches it with Drain from the spellbook while you face an NPC, and with the Ilbon knife's Kamikaze proc on a swing while you face one. A blow cannot hurt an NPC, so before, these casts were charged for nothing.
Drain, Drink of Souls, Parasite or Absorb with an NPC faced now says "It doesn't work.", as Drain already says for any other target it cannot drain, and spends nothing: no mana, no cast pose, no animation. Before, it said "You cast Drain." (or the spell's own name), took 60 mana, played the pose, showed the spell's animation over the NPC and the heal's over you, and raised your HP by 1. The NPC was untouched.
Kamikaze cast from a spellbook that was given it, with an NPC faced, now says nothing and spends nothing: no shout, no pose, and your HP is kept, as when no creature is faced. Before, it said "You cast Kamikaze.", took 120 mana, played the pose, shouted "Kamikaze~!" and left you on 10 HP. The NPC was untouched.
The Ilbon knife's Kamikaze proc (4% a swing) with an NPC faced: the swing itself is as always, and nothing else happens: no mana, no shout, no HP change. Before, each proc took 120 mana, shouted "Kamikaze~!" and left you on 10 HP (a proc shows no line or pose). In the review's 20,000 real swings at an NPC, the proc fired 791 times and cost nothing; before the fix it fired 799 times, at 120 mana each.
A client that sends a target id gets the same refusals when it aims these casts at the NPC's id, or at the id of a player on another map while facing an NPC.
Unchanged: Drain and Kamikaze at a creature, whether faced, aimed by its id or reached through an off-map player's id; the Kamikaze proc at a creature or a player; and the low-mana answers, which still come first and cost nothing (Drain says "You do not have enough mana." then "Your will is too weak."; Kamikaze and its proc say "You do not have enough mana." twice). Nothing else a stock client sees changes.
Tests: 26 new cases, each a real cast or real swings. The 20 refusal cases fail without the fix, and the controls show that the proc still lands on a creature and that Bolt at an NPC is unchanged.
Found, not fixed: Bolt and other damage spells cast at an NPC still charged for a blow that cannot hurt it; PR #351 fixed that the next day (above). And with a creature and an NPC on the tile you face, Drain or Kamikaze takes whichever the map lists first, which in the review's check of 5,498 such pairs on 240 maps was always the NPC: the cast is now refused there where it was charged, and the creature is not struck either way.
A click, approach or proposal on a busy player waits for them (PR #345)
A click on another player, the @click and @approach commands, the Approach spell, and the checks behind a Mentor or Propose cast now wait while that player's own action is being handled, including a save inside it: up to about 5 s when the database is busy. A proposal, mentorship offer or wedding question to that player opens just after their action.
Mana Gift now keeps a regen beat that lands on the target while it waits: a gift of 3,000 with a beat of 500 landing in that moment now ends the target at 4,500, where before it ended at 4,000. On a target nothing else touches, its lines, cost and result are the same.
Operators: against a session that never releases its lock, these waits hang, as @rez, @kick and the actions PR #341 changed already do. While an Approach spell waits, every Lua cast on the server waits with it.
Also fixed, rare and only when two players acted on each other at once: a "Yes" given to another box could accept a proposal; a click on a player could show nothing; a cast at another player could fail with "Valor isn't working right now."; a Mentor or Propose cast could die silently; and an approach could land at a mix of two places. A successful action is otherwise unchanged.
Doze, Blind and the other holds refuse for free before they roll, and Drain and Kamikaze strike the victim they read (PR #348)
Every player sees the hold changes, on the stock 4.95 client too. Doze, Sleep, Blind, Paralyze, Static and the slow holds now check whether they can hold the target before they roll. A target they cannot hold is refused for free: no mana, no cast pose, no wait. Before, some of those casts lost the roll first and were charged: "Something went wrong.", "You cast X.", the pose and the mana.
Doze and the rest of its family at a player on a map with PvP off: always "You can't attack that target.", free. Before, about 1 cast in 5 said "Something went wrong." and "You cast Doze.", played the pose, took 30 mana and started the spell's wait (36 s for Doze, 24 s for the other three).
The Blind, Sleep and Paralyze families, Static and the slow holds at a player on any map, and any hold at an NPC: always "It doesn't work.", free. Before, when the roll could lose it sometimes did, and that cast was charged (the Sleep family also started its wait).
The NPC Blind, which players meet as the Forsaken staff's 4% proc, is now refused for free at a player or an NPC ("It doesn't work.") and at a creature it already blinds ("You already cast that spell." or "Another spell of this type is in effect."). Before, about 1 proc in 4 lost the roll first: "Something went wrong." and 300 mana. In the review's 2,000 real swings at a creature it already blinded, 22 of 73 procs were charged, 6,600 mana in all; after the fix, none of 86 were. A cast of it from the spellbook with nothing faced is free too.
Unchanged: a target the spell can hold still rides the roll, and a lost roll still costs. How often a roll loses is the spell's own: 1 in 5 for the Doze and Sleep families, 1 in 4 for the Blinds, 1 in 10 for the slow holds. A Doze at a player on a PvP map is unchanged.
The Drain and Kamikaze changes need a client that sends a target id. The stock 4.95 client's casts carry no id, and the Ilbon knife's Kamikaze proc passes the id of the creature you face, so players on it see no change here except one animation (below).
Drain aimed by id, with a weak creature faced, at an id nobody has, at a player on your own map, or at yourself, now says "It doesn't work." and spends nothing: no mana, no pose, and the creature is untouched. Before, it said "You cast Drain.", took 60 mana, played the pose and raised your HP by the creature's, but the creature lived and the blow went to the named player (a hit in an arena, "You can't attack that target." in a town) or nowhere.
Kamikaze aimed the same three ways, with a creature faced, now says nothing and spends nothing: no shout, no pose, and your HP is kept. Before, it took 120 mana, shouted "Kamikaze~!", said "You cast Kamikaze.", played the pose and left you on 10 HP, while the blast went to the named player (a kill in an arena, "You can't attack that target." in a town) or nowhere.
Unchanged for both: a cast with no id, a creature's own id, and the id of a player on another map, which takes the creature you face. One cosmetic change reaches the stock client: when a player and a creature share the tile you face, Drain's animation now plays over the creature it strikes, not over the player. No line, cost or result changes.
Only a client that sends a target id reaches this one: on a PvP map, a Venom or a curse (Vex, Pestilence, Scourge and every other curse) aimed by id at a player on another map, with nothing faced, no longer lands on you. A Venom now says "It doesn't work.", free, with no pose. Before, you were poisoned ("Poison courses through you."), for 60 mana, "You cast Venom." and the pose. A curse now says nothing and costs nothing. Before, it cursed you or said "Something went wrong.", for the mana, the line and the pose. The NPC Blind aimed the same way now says nothing, free.
Unchanged: Heal aimed that way still heals you, and the holds and damage spells still do nothing. A cast with no id, or at an id nobody has, still lands a Venom or a curse on you on a PvP map when nothing is faced. Off a PvP map nothing changes.
Tests: 43 new cases, the hold refusals cast 40 times each. Every refusal case fails without its fix, and the controls show that a target the spell can take is still taken.
Found, not fixed: Drain heals before it strikes, so if someone else kills the creature in that instant, you keep the life. Curses and Doze still pick their target twice in one cast (PR #343, below).
A vow your partner no longer holds ends at login, and the Chapel leaves your ex's new vow alone (PR #344)
Before, a divorce or break-off at the Chapel while your partner was offline left their side in place. They logged in still married or engaged to you: nobody could propose to them, and the Chapel charged them the full price to end it. If they paid while you were online and had married again, their divorce also ended your new marriage on your side only: you were left with no spouse and no keepsake, while your new spouse stayed married to you. An engagement could do the same to a new fiancé.
Now a vow your fiancé or spouse no longer holds (they moved on, are single again, or were deleted) ends when you log in, for free. You lose the "engaged" or "married" legend and the name, and for a marriage one love keepsake. You read one status line: "Your engagement to X has ended." or "Your marriage to X has ended." The client also prints its own "Love removed." for the keepsake.
This line is sent to every player, on the stock 4.95 client too. 4.95 shows a vow only as a legend mark on the profile, so without the line the mark and the keepsake would vanish with nothing but "Love removed." to say why. The words are new, not recovered from the 2001 game.
You can be proposed to at once, and Crystal no longer offers you Break Off Engagement or Divorce for it. Before, the vow stayed until you paid the Chapel's full price to end it.
A Chapel break-off or divorce no longer touches a partner whose vow now names someone else, or nobody: their current vow survives. You pay as before.
If your partner ends the vow while your own last Chapel question is still open, your confirm no longer takes a second love keepsake, so one bought at the Chapel's shop stays. You were still charged; PR #350 made that confirm free the next day (above).
Unchanged: a pair who still name each other (nothing is written and no line is shown), and a Chapel visit while your partner is online and still yours.
Operators: when a partner's saved character cannot be read, the login keeps the vow, logs it and asks again at the next login. A login can set or clear the partner row's unreadable_since marker, as the partner's own login would, but it never writes the partner's character data. No schema change.
Tests: 33 new cases through real logins and real Chapel dialogs, including both partners answering at the Chapel at once for 100 rounds and a login racing the partner's Chapel visit for 60 rounds. Before the fix, 24 of the first 31 failed; the other seven guard against clearing too much, and two more pin how a login waits for an online partner.
Found, not fixed: if your partner ended the vow while your last Chapel question was open, your confirm was still charged the full price, and an online partner whose vow the Chapel ended was told only "Love removed."; PR #350 fixed both the next day (above). An "engaged" or "married" legend with no name behind it, left by a crash between the proposal's two saves, still blocks proposals and has no Chapel entry.
Server: damage, heals, buffs and curses aimed by id stay on your map, and Mana Steal at yourself is refused
Damage, heals, buffs and curses aimed by id stay on your map, and Mana Steal at yourself is refused (PR #343)
Everything here needs a client that sends a target id. The stock 4.95 client aims by facing and sends no id, and its weapon procs pass only the id of what you face on your own map, so players on it see no change.
A damage spell, a curse, a venom or a Doze aimed by id at a player on another map no longer reaches them, from an arena into a town or anywhere else. Before, from an arena, Ignite, Bolt and Hailstorm hit a player standing in a town, and Venom, Vex and Doze landed on them.
A heal, a buff or a ward aimed by id at a player on another map no longer reaches them. Before, Heal, Valor and Harden Armor reached a player on any map.
Instead, with something faced on your own map, the cast lands on it, as a cast with no id does. With nothing faced, you get what an id nobody has gets: a damage spell hits nobody and costs nothing (a spell that spends first, like Bolt, still says "You cast Bolt." for its mana); a heal heals you; a buff does nothing and costs nothing; a ward says "It doesn't work."; and on a PvP map a venom or a curse lands on you. That last one changed the next day: see PR #348 above.
Endear, Amnesia, Confuse and Drain aimed by id at a player on another map, with a creature faced, now take the creature. Before, they refused silently, and Drain said "You can't attack that target." and left the creature alive.
Fissure, Lava Surge, Volcanic Blast and the Inferno and Earthquake spells aimed by id at a player on another map now centre on what you face, or on you. Before, they centred on that player's coordinates on your map.
Mana Steal at yourself (Inspiration, Draw Energy, Harness Power, Combine Focus) now says "It doesn't work.", with no cast pose and nothing spent, as Mana Gift always has. Before, in a party it said "You cast X." and your mana went to 0; out of a party it said "They must be in your group." and cost nothing. Aiming at yourself also needs a client that sends your own id: the stock client never targets you this way.
Unchanged: casts with no id, weapon procs, every refusal line, the PvP checks, a cast by id at a target on your own map, and an id nobody has, which still ignores what you face.
Tests: 30 new cases, each shown to fail without its fix.
Found, not fixed: a target who changes maps in the instant between the aim and the hit is still reached. Curses and Doze pick their target twice in one cast. If the player you aim at leaves your map in between, the second pick falls to whoever you face or to you, without checking their slot: a protected player you face can be cursed, you can curse yourself, or Doze's 1.3x can arm on you. Whether the 5.33 client sends an id has not been checked. Two older defects found in review, Drain and Kamikaze at an id nobody has, and Doze and Blind charging for a lost roll before their free refusal, are fixed by PR #348 (above).
Server: a map with one broken spawn entry can still be entered
A broken spawn entry costs only its own creature, not the whole map entry (PR #346)
Entering a map with one broken spawn entry now works. The rest of its monsters appear, and only the broken entry's creature is missing. Before, the entry failed: with a broken group entry, nobody could get onto that map at all.
While a spawn group has a broken member, its other members refill about once a second whenever anyone is on the map, instead of on the group's timer. No map has a broken spawn entry today, so nothing changes in play now.
Operators: each broken entry writes an Error line when someone enters the map, with its full stack once an hour and again after @reload.
@reload behaves as before.
Two new tests, each shown to fail without the fix.
Found, not fixed: @reload's rebuild is not guarded the same way, and whether a group with a broken member should keep its timer is still open.
Server: a Dispell at another player wakes and cures them, fails free and spares GMs, and clears their spells safely
Five more actions on another player change their state safely (PR #341)
Valor and its family, Sanctuary and its family, Mana Gift and Mana Steal cast at another player, breaking off an engagement or divorcing at the Chapel while your partner is online, and a GM's @mute now change the other player's state while it is locked.
On the live server these could race with the other player's own actions. On a development (Debug) server a Valor at another player, or a Chapel break-off or divorce with the partner online, crashed the game process. Both are fixed.
What each action does, says and costs is unchanged.
A Dispell on yourself follows the same rules, and a spell aimed at a player stays on your map (PR #342)
A Dispell on yourself now works like one at another player: a lost roll shows only "Something went wrong." and costs nothing, and a won cast also ends your poison.
A Dispell, Mana Steal, Mana Gift, Resurrect or Divination aimed at a player by id can no longer reach someone on another map. It falls back to the player you face, as an unknown target does.
The stock 4.95 client aims by facing, not by id, so players on it see no change.
A Dispell at another player wakes and cures them, fails free, and spares GMs (PR #340)
A won Dispell, Remove Magic, Return Natural or Restore Balance cast at another player now also wakes them from a Doze or Sleep and ends their poison. They read "You wake up." and "The poison passes.". Before, their status box cleared but they stayed asleep and poisoned.
A Dispell at another player that loses its roll now shows only "Something went wrong." and costs no mana. Before, it also said "You cast Dispell." and cost 200.
A Dispell cast at a GM fizzles and costs nothing.
A Dispell on yourself is unchanged for now.
These follow the era spell pages, which say Dispell removes all spells cast on the target, and RTK for the failed roll.
A Dispell on another player clears their spells safely (PR #339)
Dispell, Remove Magic, Return Natural and Restore Balance cast at another player now clear that player's spells while their state is locked, the way other spells that act on a player already do.
On the live server, a Dispell could race with the target's own actions. On a development (Debug) server it crashed the game process. Both are fixed.
What a won or lost Dispell does, says and costs is unchanged.
Tests: nine new cases, including two players dispelling each other 300 times while buffing themselves.
Server: the aligned furies, Serpent's and Spirit Fury, Second Sight, Barrier and Human Barrier work
Twenty-nine self-buffs that took their mana and did nothing now work (PR #338, issue #334)
Aligned Warriors' and Rogues' furies work. Soul's Rage, Spirit of the Forest and Augmentation arm x2; Filling the Soul, Spirit of the Wild and Ohaeng's Grace arm x3; Strength of Ancestors, Spirit of the Dragon and Ohaeng's Anger arm x4. Each lasts 625 s.
Serpent's Fury arms x4 and Spirit Fury x5, for 375 s each. Only one fury runs at a time: a second is refused at no cost.
Second Sight costs 140 mana, not 240, and runs 325 s. When cast, and every 15 s after, the Poet reads "<name> is Hidden in the Area" for each invisible player within 9 tiles.
Barrier costs 300 mana and lasts 22 s. Creatures on the four tiles beside the Poet are frozen until it ends, and no creature can step onto those tiles. This includes the Poet's own pets and bosses.
Human Barrier lasts 22 s, with an 86 s wait before it can be cast again. Every living player on the four tiles is paralysed until it ends, in towns too: no walking, turning, attacking or casting. No other player can walk onto the tiles.
A Human Barrier hold ends after 22 s, at death, or when another player's Dispell, Remove Magic, Return Natural or Restore Balance wins its roll. Being hit and Cure Paralysis do not free you. A ghost is not held.
Casting any of these again while it runs is refused at no cost. Before, every recast took the mana again.
Tests: 56 new cases, each shown to fail without its fix, including a race of two Poets casting Human Barrier at each other.
Server: a changed kingdom, face, gender or totem stays after you log out, and Approach and Summon fizzle in areas that do not allow approaching
2026-10-08 to 2026-10-09 · Essorcal · Server PRs #336, #337
A changed kingdom, face, gender or totem stays after you log out (PR #337)
Every login used to put back the kingdom, face, gender and totem picked when the character was made. Changes now stay.
Kingdom: joining one at a town crier (and the 20 gold acorns it costs), becoming Neutral at Rotah, and @nation. The HUD crest, Return, the revive point and the Approach/Summon kingdom check all follow the kept kingdom.
Face and gender bought from the rogue-guild shaman.
Totem: worship at a totem shrine, and @totem. Totem time and its experience bonus follow the kept totem.
@nation past 7 now sets 7 and says so, as @totem past 3 sets 3.
New characters: no change.
Tests: 38 new cases through a real login and logout, each shown to fail without its fix.
Approach and Summon fizzle in areas that do not allow approaching (PR #336)
Approach to someone who stands in an area that does not allow approaching now says "Fizzle." and costs nothing. Before, it took you there.
Summon says "Fizzle." at no cost when you stand in such an area, or when the person you summon does.
The areas players will notice are the newbie tutorial fields, Guol and Tiger's Steps. Others include The Old Man, the Nagnang Dark Forest and palace areas, and the Buya Library Caverns. No town is affected.
Approach out of such an area still works where warping out is allowed, as on Tiger's Steps.
In some casts where a map is also one you cannot warp out of, the refusal now reads "Fizzle." instead of "That does not work here.": for example, summoning someone who stands in Guol, the tutorial or a Mythic zodiac room.
Tests: twelve new cases, each shown to fail without its fix, including the line a player reads where both refusals apply.
Server: Approach and Summon work, 10- and 11-letter account names log in every time, and @kill keeps townsfolk and lets the spawn points it empties refill
2026-10-06 to 2026-10-07 · Essorcal · Server PRs #320, #315, #325, #335
Approach and Summon work (PR #325, issue #313)
Mages, Rogues and Poets can use Approach and Summon. Type the name of someone in your group: Approach takes you to them, and Summon brings them to you. Each costs 30 mana. Before, they cost 5 mana and did nothing.
They work only on an online member of your group from your own kingdom. Two Neutral characters may use them on each other; a Neutral and a citizen of a kingdom cannot.
"Fizzle." at no cost for a wrong name, yourself, someone outside your group or kingdom, an indoor destination, a PvP map on either side, the destination's level, vita or mana limits, staff, or (Summon) a dead player.
"That does not work here." when the person being moved stands where warping out is blocked, and "You do not have enough mana." under 30 mana.
If the person logs out before they are moved, Summon fizzles and costs nothing. Summoning someone in the middle of a trade cancels the trade for both traders.
You arrive on the first free tile north, east, south or west of the person, or on their tile if all four are taken.
Not yet: the original game's areas that do not allow approaching. Until they are added, which is next, the spells can reach places such as the newbie tutorial fields, Guol and Tiger's Steps.
Tests: 37 cases for the two spells and six race tests, each guard shown to fail without its fix.
10- and 11-letter account names log in every time (PR #315)
Accounts with 10- or 11-letter names log in every time again. Before, the second 11-letter login on the server was refused at the game door, and 10-letter logins began failing after a few dozen.
If an 11-letter account logs in again before its first login reaches the world, the account enters once and the other attempt is refused. Before, both were refused.
Used login tickets are now deleted at every login, so the table stays small.
Operators: a login ticket that cannot be saved now writes a Warn line. The database moves to schema 4, and a server build from before this change will not start on it. Roll forward, not back.
Tests: new tests for both name lengths, two logins in flight, the deploy window in both directions and the migration. Every guard is shown to fail without its fix.
@kill keeps townsfolk, and the spawn points it empties refill (PR #320)
After a GM's @kill, spawn-point creatures, rare bosses included, come back on their usual respawn timers. Before, they stayed gone until @reload or a restart.
@kill no longer removes NPCs, so townsfolk no longer vanish when a GM clears a map in town. Its reply no longer counts them: on a map with only NPCs it says "no mobs to clear".
@kill does not count as a kill, so it does not start a rare boss's death cooldown. Citelam and Maletic can come back on their point's first refill, 3 to 4.5 minutes after @kill, 1 time in 10 per refill. After a real kill they still wait at least 30 minutes.
Ambush and frigid traps are not creatures and are untouched.
Tests: four new tests. Three of them are shown to fail without the fix.
Found, not fixed: the @kill help row still says "every mob", and @mob accepts 0 or negative HP.
Review follow-ups from 2026-10-05 (PR #335)
Nothing players, GMs or operators see changes.
Tests: the torn-tile test walks until it has really watched the mover, two test classes no longer share map ids, and a new test reads Sources.csv as strict CSV after its row 21 is fixed.
The check from PR #326 now judges Debug-only code in the Release run too.
Tooling: Serve.ps1 -Stop no longer skips PowerShell 7 when a Windows PowerShell error quotes its marker line.
One server comment and three test summaries now say what the code does.
Server: the Inferno spells take all your mana and wait 70 seconds, six Rogue spells get their cast delay, Wind dancer and the Champions recast every 8 minutes for no mana, and entering a map no longer puts a trap or a group creature on your tile
The Inferno spells take all your mana and wait, six Rogue spells get their cast delay, and a granted Spirit Blade works (PR #322)
Spirit Blade works: 10,000 mana for x9 on the weapon part of your swings, until the weapon comes off or you log out or die. Casting it again while it is up is refused and costs nothing. Before, it cost 5 mana and did nothing. Only a GM grant reaches this spell today.
Inferno, Death's Door, Nature's Denial and Steel Storm take all your mana and then wait 70 seconds, even when the cast hits nobody. A cast refused for lack of mana costs nothing. Damage is unchanged.
Afterlife's Embrace, Ming-Ken's Judgement and Calculating Blow now have Lethal Strike's 1-second cast delay. The Void's Measure, Beastly Frenzy and Tilting the Balance now have Desperate Attack's. Before, they had none.
Fissure, Lava Surge, Volcanic Blast and the Earthquake spells keep their flat cost and no wait. Nothing else changes.
Twenty-four new tests, each shown to fail without its fix, and eleven that check what did not change.
Found, not fixed: the six Warrior reskins of Berserk and Whirlwind have the same missing cast delay. That needs a decision.
Wind dancer and the Champions recast every 8 minutes and cost no mana (PR #317)
Wind dancer and the Kwi-Sin, Ming-Ken and Ohaeng Champions can be summoned once every 8 minutes. Before, there was no wait. Summoning again too soon is refused with "<spell> isn't ready yet."
They cost no mana. Before, they cost 10.
Wind warrior, the Avatars and every other summon are unchanged.
Found, not fixed: Wind master has no creature to summon. A cast spends 10 mana and summons nothing.
Entering a map no longer puts a trap or a group creature on your tile (PR #319, issue #122)
Entering a map no longer puts a creature from a timed spawn group, a hidden ambush trap or one of Sute's Cave's cold tiles on the tile you arrive on.
Tests: four new tests. Three of them, one for each placement, are shown to fail without the fix.
Found, not fixed: a spawn point can still place its creature on a player's tile, when a map is first entered or when the creature respawns.
Found, not fixed: a broken spawn row still fails the whole map entry, a follow-up from PR #292.
Collecting a parcel while the database is failing answers at once (PR #318)
While the database is failing, collecting a parcel now says "try me again in a moment" at once and keeps the parcel. Before, the claim repeated until the database recovered.
This fixes the parcel retry found under PR #312. Every other parcel claim works as before, and so does mail.
Operators: the log gets one [db] warning per failed claim instead of one per repeat, and a "parcel claim FAILED ... parcel kept" line once the player clicks past the box.
Tests: a new test with three cases of a refused or failed claim, each shown to fail without its fix.
@reload says when a Lua edit is rejected (PR #323, issue #113)
@reload now starts its reply with "*** REJECTED (still running the previous version, see log): <file> ***" when a Lua script edit does not take: a compile error, a missing global table or a missing file. The old script keeps running, as before.
The content load log counts that script as not kept (1/0), and adds a REJECTED warning line for a compile error or a missing global table.
Players see no change: the same scripts run.
Tests: twelve new reload cases, one for each of the four scripts and three kinds of failure, each shown to fail without its fix.
The @class and @align help rows fit the status pane (PR #316)
GMs and testers see the class and alignment lists in @help, and in bare @class's usage line, with spaces around the bars. They wrap between names instead of splitting mid-word.
What the commands accept is unchanged. Players never see these rows.
Tests: PR #290 had left both rows as exceptions in the pane-width test. No row is exempt now, so a new row too wide for the pane fails the test.
Both quest maps go through QuestState, and the login close is widened as a guard (PR #321, issues #307, #308, #309)
QuestStage, SetQuestStage, QuestCounter, QuestStr and SetQuestStr are gone. Code names quest state through Quest(name), or Quest(QuestState.Registry) for a flat registry key.
The quest string registry is now text slots under the same rules, saved under the same keys, such as minor_quest and mentor. A text slot read as a number, or a number slot read as text, throws.
SetNation clears the bound home through QuestState too, with the same single save.
@quest and @questreset still show and clear both maps. No character data is migrated: every saved key stays byte for byte what it was.
Tests: the check that fails on any direct use of the quest map now covers the text map too, and no longer exempts SetNation. A new test walks every armor-quest step and every MinorQuests.csv mob through QuestState, so a data row it cannot resolve fails the test run.
PR #311 closed the connection when a login hit an error after the account's slot was reserved. An unexpected error before the slot is reserved now closes it too. This is a guard with no trigger today: no step before the slot is reserved throws, so nothing a player sees changes.
Tooling: Serve.ps1 -Stop says why a stop signal failed, and tries PowerShell 7 (PR #327)
When Serve.ps1 -Stop cannot send a server its stop signal, it now prints the reason instead of only "helper exit 1".
If Windows PowerShell cannot run the signal helper, PowerShell 7 runs it before anything is terminated. A helper that may already have signalled is never followed by a second one.
The forced stops seen in reviews on 2026-09-28 and 09-29 came from an older copy of Serve.ps1; the current one stops a pair in about a second.
A test pins the restart_at fallback's timing against the server's poll, tick and grace.
Nothing players see changes.
Tests: anything that changes the whole test process runs on its own (PR #326)
Tests that change something every test shares (the log, the console, content tables, settings) now run on their own, after the rest.
A new check fails the test run, naming the class and the line, when a test touches one of those from anywhere else.
The Lua-gate deadlock test stays fast however full the suite gets, and no deadlock test reads a paused machine as a hang.
A full Debug test run takes about 55 s, down from about 70 s.
Nothing in the game changes.
Review follow-ups from #311 and #314 (PR #324)
Tests: four save-race tests no longer check a value that could never be false. Each still fails on its saved row when its guard is removed.
Comments, two test summaries and Protocol.md now describe the one published totals snapshot from PR #314, not the old gear cache.
Nothing changes in game.
Server: failed logins no longer hang on the loading screen, group kills and parcel claims save safely during a re-login, PvP and divination read a consistent target, and quest state goes through one type
A login that fails before you reach the world no longer leaves you on the loading screen (PR #311)
If your login hits an error after the server has reserved your account's slot but before you enter the world, you are now disconnected instead of waiting on the loading screen. Log in again and it works.
An error earlier than that, before the slot is reserved, still leaves the connection open, as before.
If your old session's final save fails while you log in again elsewhere, you get in with your last good save, and an older autosave can no longer land on top of it afterwards. The lost save is logged.
The three login refusals give the account's slot back through one helper.
Eight new tests, each shown to fail without its fix.
Group kill credit and parcel claims save safely during a re-login (PR #312)
A group kill's quest credit and exp now save together for each member, even if the same account logs in elsewhere at that moment. Before, a member could keep the kill credit and lose the exp.
Collecting a parcel or opening a letter with an item can no longer be undone by an autosave that was already on its way.
Parcel and mail claims follow the same rule as every other save when an account logs in elsewhere.
Fourteen new tests, each shown to fail without its fix.
Found, not fixed: a parcel claim retries without pause while the database refuses the character's row.
PvP, armor-reading spells and divination see a consistent target (PR #314)
A swing, an armor-reading spell or a divination on a player who is changing gear or buffs at that moment no longer mixes two states, and can no longer fail with an error.
The numbers you see are unchanged.
Cost measured: about 128 bytes and under a microsecond per gear or buff change.
Found, not fixed: three target-spell helpers still change the target without its lock.
Quest state goes through one QuestState type (PR #310, issue #51)
Each quest's saved values are named by quest and slot (flag, kills, timer, count) through QuestState.
One alias table maps every slot to the key characters were already saved under. No character data is migrated.
A character with all 75 saved quest keys loads and saves byte for byte the same.
NPC scripts reach quest state through one accessor, ctx.Quest(name). Stage, SetStage, Counter, Reg, SetReg, LuaReg and LuaSetReg are gone.
The Lua quest verbs and the spell verbs' reg and setReg are unchanged.
A quest slot missing from the alias table now throws instead of reading 0.
Tests pin every slot to its saved key and fail on any direct use of the quest map.
docs/common/Quest-Registry.md lists every slot and its saved key.
Follow-ups: #307, #308, #309.
Review follow-ups from #301, #302 and #304 (PR #306)
The name-length comment says the 11-letter cap comes from the handoff field.
Two comments now say the checked name is the one creation uses, not a fallback.
The character-creation check also fails on a second ungated create or a reassignment.
"Nine" MageTrainerNpc corrected to twelve.
dagger_uniform is limited in NpcAbilities.csv to Maro, Maso and Dagger. Nothing changes in game: the other rogue trainers never offered it.
Tooling
The Codex sandbox build failure is solved: build outputs the sandbox created before a clone was granted to it could not be overwritten. The fix is to clear bin and obj before a Codex build.
merge-ready --cleanup now deletes a merged PR set's ci/ branch.
New retail test sheet and a passive Frida probe for the four damage rulings (#77, #78, #82, #200).
Server: new names stop at 11 letters, faces 1 to 4 create, errors at logout or re-login no longer leave you stuck, NPC abilities can be limited to particular NPCs, and Serve.ps1 stops cleanly from PowerShell 7
New character names stop at 11 letters (PR #301, issue #299)
New names can be 3 to 11 letters. A 12-letter name used to be created and then could never enter the world. The create screen now refuses it with "Names must be 3 to 11 letters."
Every length refusal now says "3 to 11".
No existing character is affected: the server has no 12-letter names.
The cap is one constant, taken from the 12 bytes the 4.95 client's handoff field carries back.
Picking face 1 to 4 at creation works (PR #304)
Choosing face 1, 2, 3 or 4 when creating a character no longer refuses your name. The character is created.
The 0x04 create packet carries no name. Creation always uses the name the 0x02 check accepted, and the name check still runs on it.
On 5.33, a few more face ids may be fixed the same way. That depends on the length of 5.33's create packet, which has not been recorded yet.
Errors at logout or re-login no longer leave you stuck (PR #303)
If leaving a trade or a group hits an error while you log out, you still leave the map, your character is saved and your login is freed.
If your old session cannot be saved when you log in again, you get in with your last good save and the old connection is closed. Before, the re-login failed and the account stayed stuck.
A login that fails partway into the world no longer leaves the account marked as online once that connection drops.
In that re-login case, an autosave already on its way is dropped, so the save you load is the final one.
Normal logout, login, trade and party are unchanged.
Review loose ends (PR #300)
In the moment between a re-login and the old connection closing, a blind creature no longer swings at the old session. It picks the next adjacent live player, as creatures that can see already do.
The spawn-row repeat log line now starts with "world tick phase", like the other tick-phase lines.
Comments and Locking.md updated for the replaced-session latch and the Lua gate.
NPC abilities can be limited to particular NPCs or maps (PR #302, issue #50)
NpcAbilities.csv entries can name the NPC ids or maps they apply to, for example sute@39 or alignment@map:324;325;326.
Seven quests' private NPC lists moved into the CSV: Sute, Mage Stone, both Nagnang shields, Poet's whip, White Moon Axe and Forgotten Past.
Four quest data tables stay in code, each tested against its CSV row.
The tutorial quest is now a CSV row, and Quests.cs is gone.
A bad id or map in the CSV is logged at load, and the ability stays off instead of reaching every NPC of that type.
Nothing changes in game.
Serve.ps1 -Stop works from PowerShell 7 (PR #305)
Serve.ps1 -Stop run from PowerShell 7 now delivers Ctrl+C to the servers, which save connected players and exit. Before, both were killed after 85 s with no save.
If the signal cannot be delivered, the script now says so, and the game server's restart_at fallback fires instead of being ignored as stale.
Dev tool only. Nothing changes on the live server.
Server: an older deploy no longer replaces a newer one, a replaced login's old connection stops acting in the world, and a late group share at logout is kept
2026-09-25 to 2026-09-27 · Essorcal · Server PRs #296, #297, #298
A late group share at logout is kept, and a replaced login writes nothing (PR #298, issue #168)
A group kill share that lands the moment you log out is kept, even if you log straight back in. The new login waits for the old session's last save, normally well under a millisecond.
The old connection of a replaced login no longer saves over you, finishes a trade (both sides get "Exchange cancelled." and nothing moves) or takes a party share.
A character that can't be saved is disconnected cleanly with "save LOST" in the log, instead of being left on a frozen screen.
The world stops acting on a replaced session (PR #297)
Logging in again while your old connection is still open: until the old connection finishes closing, a few milliseconds, it can't be clicked, targeted or damaged, and it can't die, so there is no duplicate death pile.
An older deploy no longer replaces a newer one (PR #296)
When two merges land close together, the older deploy now skips and says so, instead of putting an older build on the server after a newer one has gone up.
It did this on its first real race: #297's build went up, and #296's older one skipped five seconds later.
CI runs a 61-check test of this guard on every push.
Server: trades open safely under both players' locks, one bad spawn row costs only itself, a failing heartbeat step no longer loses the beat, and back-to-back merges deploy cleanly
A trade opens under both players' locks and never on one who is leaving (PR #291, issues #173, #188)
A trade can no longer be opened on a player who is disconnecting.
Two players starting a trade with the same person at the same moment no longer both get a window. In that race, the second player sees "That person refuses to exchange with you.", or "You are already trading." if the two were opening on each other.
Tests: the test recorder now takes frames from two threads at once, so race tests can check exactly what a player was told.
One bad spawn row costs only itself (PR #292)
A broken spawn row now stops only its own creature from appearing. The rest of that map's creatures, and other maps', spawn as normal.
While a group has a broken member, its other members refill about every second until the content is fixed.
The log names the creature and map, and repeats are counted instead of re-logged.
A failing heartbeat step costs only the rest of that step (PR #289, issues #106, #121)
The world heartbeat no longer loses a whole beat when one of its early steps fails: spawning, refills, morph and trap expiry, forage, the clock or the weather. The failing step is skipped for that beat, and everything else still runs and is sent.
Failures are logged once with their stack, then as a one-line count per beat.
@reload no longer leaves stale entries in the spawn bookkeeping for the creatures it tore down.
@help rows checked against the pane width, and @clock's marker matches the wire (PR #290, issues #176, #159)
Tests: every @help row is now checked for words too wide for the status pane, not just the first page. @class and @align already have option lists wider than the pane, and are left as they are for now.
@clock's pinned-hour marker had a double space in the code that the client never received. The code now matches what is sent.
The Lua gate asserts no world lock is held on the way in (PR #293, issue #90)
Debug builds now stop loudly if code enters the Lua script gate while holding the world lock. No change to release builds.
Weapon swap and unequip frames pinned (PR #295)
Tests: weapon swaps and unequips, with and without a weapon enchant, now have a byte-for-byte test of what the client receives. The proposed reordering of the enchant drop was dropped because it would have changed a HUD frame.
The thread-start check scans all of the server (PR #294, issue #195)
Tests: the thread-start check now scans all of the server code, not two files, and a comment can no longer fool it.
Back-to-back merges no longer turn the deploy red (PR #287)
A deploy that lands within 3 minutes of the previous one used to fail on the login restart timer, after it had already staged the new build and moved the game restart to it.
It now passes with a warning in that case, when the commit is master's newest.
Every other failure still fails, including that same collision for a commit that is not master's newest, since it may have put an older build in place.
Players see no difference: one warned restart per burst of merges, as before.
Test-Branch.ps1 -Shard k/n (PR #288)
Runs every n-th script of the test client suite, so the full suite can be split across calls: -Shard 1/2, then -Shard 2/2.
A malformed value, or a shard with no scripts, exits 2 instead of passing.
Each shard prints "(x of y scripts)". The shards cover the whole suite only if y is the same for every call.
Server: stripped gear drops its bonuses at once, failed saves are retried, @karma refuses NaN, messages reach a re-logged player, and three flaky tests fixed
2026-09-24 · Essorcal · Server PRs #278, #280 to #286
Gear taken off by the gender-change NPC or an armor-quest turn-in drops its bonuses at once (PR #286)
Gear removed by the gender-change NPC, or handed in while worn to an armor-quest guildmaster, now drops its bonuses at once. Before, they stayed until the next equip change or relog.
Current HP and MP clamp to the lower maximums at once, as with an ordinary unequip.
A spell script cannot leave a living player at 0 hp, and @clearinv drops the cleared gear's bonuses (PR #281, issues #175, #206)
The Lua setHp setter now stops at 1 hp for a living player. No shipped spell passes 0.
@clearinv now drops the cleared gear's bonuses at once, and current HP and MP clamp to the new maximums.
Failed character saves are retried (PR #282, issues #179, #88)
A save that fails while taking its snapshot is now retried by the next autosave, instead of the character being marked saved.
A full character save now counts for the autosave timer, so it is no longer followed straight away by an identical second write.
Messages reach a player who has just logged in again (PR #283, issues #183, #87)
During a re-login, a whisper or GM command aimed at your name now reaches the new session, instead of the old one that is closing.
A stale comment in Session.Resolve.cs corrected.
@karma refuses NaN and Infinity (PR #285)
@karma NaN, Infinity and -Infinity are now refused with a reply. Before, they left the character unable to save until a GM set karma again.
Three flaky tests fixed (PRs #278, #280, #284, issue #187)
Tests: the broadcast allocation check counts the calls that allocate instead of summing a pass's bytes, so a Windows memory-pressure trim no longer fails it.
Tests: the normal-peer login check reads every frame before the connection is torn down, so a slow thread pool at the start of the suite no longer fails it.
Tests: every test that swaps content tables now runs in the tile-translation collection, fixing an intermittent Release failure.
Server: the wire dump defaults off, a throwing creature costs the world lock a queued record, bad Tile533Map rows are refused, the send path gets counters, and the world lock's hold rule is written down
The game process defaults its wire dump off (PR #276)
The game server no longer dumps every frame's bytes to its log when P1998_LOG_WIRE is unset. The default is now off. Set P1998_LOG_WIRE=1 for protocol work, never on a live server.
A throwing creature costs the world lock one queued record, not a stack (PR #274)
A creature that throws an error is now recorded under the world lock and logged after it. Before, a full stack trace was written under the lock for every creature, every beat.
The first throw of each fault is still logged with its stack, and repeats become one count line per beat.
Creatures and maps that throw are skipped exactly as before.
Tile533Map rows are checked whole before they are expanded (PR #273, issue #128)
A Tile533Map.csv row whose range falls outside the tile ids is skipped and logged, instead of being able to spin the loader. All 1,200 current rows pass, so the 5.33 tile table is unchanged.
The game channel's send path gets counters (PR #275)
run/status.json gains five counters for the game channel: frames and bytes sent, slow sends, and how many of those were slow in the queue or in the socket write.
No change to what is sent or when.
Locking.md: how long a world lock hold may be (PR #271)
A new section says that waiting on a contended lock grows with the square of each hold's length, so on the world lock a long hold costs more than a frequent one.
Two flaky tests fixed (PRs #272, #277)
Tests: the login drain check measures when the writer finishes, not when the test wakes, so a starved thread pool no longer fails it.
Tests: a test that shuts the log down now keeps other tests' log captures out until it restarts it. This fixes a master failure on run 35895702560.
Server: the status document counts the viewers the tick swept, and four holds measure what the tick sweep skip does under load
The status document says how many viewers the tick swept, not just how many beats ran (PR #270)
run/status.json gains two cumulative counters at the end of the document, sweepViewers and sweepsRun, counted since process start like ticks.
sweepViewers counts every viewer-beat the tick considered: one per player on every populated map, every beat, whether or not P1998_TICK_SWEEP_SKIP then skipped it. sweepsRun counts how many of those actually swept.
Over a span, the share the skip saved is 1 - Δ sweepsRun / Δ sweepViewers. With the skip off the two deltas are equal.
Written by the tick thread alone, with no lock, no new acquisition and no Interlocked. The status thread reads them with Volatile.Read, as it already reads ticks and slow ticks.
Documented in Server/StatusFile.cs and in a new section of docs/common/Configuration.md, outside the generated block.
Two new facts in Tests/TickSweepSkipTests.cs, and Tests/StatusFileTests.cs checks that the rendered document carries the world's own values. Deleting either increment turns its fact red.
Suite green on both builds, Release 1,241 and Debug 1,251. The coordinator's saved Debug run had four timing failures in LoginOutboundTests and LogLineSinkTests that passed when re-run alone. Tier C, coordinator-verified.
No behaviour change. It adds two additions per beat on the tick thread and two numbers in a file the game never reads.
Measurement: what the tick sweep skip does under load (four hold assignments, PR #269's switch, no product change; laptop, Release, 400 players, tick 333 ms)
The skip fires only where viewers stand still. On the standing clump (map 4711, every step refused) the skip-on arm swept 154,800 of 228,400 viewer-beats and skipped 32.2% of them, 73,600 in about three minutes. This was the first clump hold with the counters.
On every walking-spread hold the skip share is 0.000 on both arms. Every viewer is dirty every beat, so the two arms do identical work: 190,400 swept of 190,400 considered, exactly 400 a beat.
What the clump skip buys: the viewport phase went from 6.67 to 5.94 ms/s (0.891). The two earlier clump pairs, run before the counters existed, read 0.938 and 0.746. A tenth off rather than a third means the sweeps it skips are the cheap ones.
Process CPU does not move on the clump. The whole beat is about 11 ms/s against a process at about half a core, and the rest is the chat send path, with over 6,000 SLOW SEND lines per window.
That puts a ceiling on a per-region sweep key for this workload: about 10% of a 6.7 ms/s phase. It is deferred on that evidence.
Release clump rows now exist: base beat 11.7 to 12.9 ms/s, viewports 6.67 to 7.53 ms/s, CPU 49.7% to 51.2% of one core, over three holds. Debug read 55 to 64, 45 to 50 and 55% to 58%.
Holds of 161 s cannot read a phase timing, but 200-round holds (about 1,195 s) can. The base arm's (3) viewports band is 52.8% over the 161 s holds and 11.3% over three 200-round holds on three builds. The (5) regen band goes from 131.5% to 21.3%. Process-level readings (CPU, CPU per step, steps per second) repeat to a few percent at either length.
Any hold that reads a phase now uses the 200-round script.
Withdrawn as scatter: the first assignment's "the bookkeeping costs tick time on a walking map", which rested on one head hold at 37.77 ms/s, and the second's "the head arm is the noisy one". No walking-map head/base ratio on the phase can be quoted as an effect of #269, because the counter shows the switch does nothing there.
The 200-round skip-on hold read 0.76 of its base on the viewport phase with identical sweep counts. That is one hold per arm with no mechanism, and it is reported as scatter.
No player-visible difference on any of the fourteen holds. The checkpoint-internal control, the reading of record, found no neighbour held staler with the skip on than off on any arm. Both clump arms were exact, 159,600 of 159,600, including the arm that skipped 73,600 sweeps. Three spread arms, on both settings, had one pair each off by 2 to 4 tiles.
Dev process
CI: one login test, LoginOutboundTests.TheRedirectsLastFrameReachesASlowButReadingPeerBeforeTheClose, flaked three times in one day: on PR #270's own run (1,259 ms), on the master run after the merge (2,245 ms) and in one local Debug suite (1,506 ms). It fails on its own 1,000 ms drain bound. Both CI runs passed on a rerun, and the master rerun deployed.
Three hold workers died mid-hold on API HTTP 500s during a status-page incident (2026-09-22, 00:50 to 02:10 UTC). Resuming the same transcript failed again, and a fresh agent with a state note worked. Every hold packet now keeps a progress.md ledger line per hold, so a relaunch resumes from the ledger.
A hold is post-processed before the next machine-window check, so the before-launch snapshot does not catch the worker's own Python.
Workflow.ps1 metrics over the review records: 118 merged PRs with records. Tier A 34 merged, median 2 review rounds, 0 escaped defects. Tier B 43, median 2 rounds, 2 escaped. Tier C 41, median 1 round, 1 escaped.
Five artefact reads this window, one per report. The first hold report's two HIGHs were an arithmetic slip (38 times, not 3.7) and a sample count that matched neither file set. The third report's MEDIUM was a band width quoted as a distance from the mean (35% below, not 58%). The fourth report came back with no findings. None moved a conclusion, and every correction is appended to its report.
Server: the tick skips viewers whose map has not changed, the dev launcher regenerates the runtimeconfig per branch and dumps no frames by default, the GC memory budget priced on two Release pairs and closed, a tick snapshot pool dropped on its numbers, and what a hold on the world lock costs
The tick sweeps a viewer's viewport only when something it reads has changed (PR #269, refs the PR #266 pooling report)
The world tick ran the peer, mob and ground-item sweeps for every player on every populated map every beat, and on a still map every one of those decisions was "send nothing". Each map now counts every write a sweep reads: the player, mob and floor-item rosters, every mob tile through the world's one mob commit, every player tile through the one seam all world writers use. A viewer whose map count and own pending flag are unchanged skips its three sweeps.
Release, 400 players and 305 mobs on one map with nobody moving: 2,100,116 ns a beat to 10,062 ns, 208 times less. Debug 7,534,610 to 25,304, 298 times. What is left is the snapshot alone.
The key is per map, so one walker anywhere un-skips every viewer of that map. On the walking spread it is worth nothing; it is for standing populations and the many quiet maps a live server has. With 5% of players stepping, or 5% of mobs, the two arms are inside the run spread, so the bookkeeping costs nothing measurable; the head allocates 120 B a beat more, the snapshot tuple growing by two fields.
No visible change: one scripted scenario (a peer walking in and out of the rect, a mob crossing it, an item dropped and picked up, a peer joining and leaving the map, the redraw pair every morph and equip change sends, and the viewer itself moving) runs with the switch off and on in one process, and the viewer's two frame streams are identical opcode for opcode, id for id, tile for tile.
No new lock, no wider hold, no order change. Every bump is one increment inside an acquisition the write already held; the count is captured inside the reconcile's own acquisition with the roster and compared outside against the captured value, never a live read.
P1998_TICK_SWEEP_SKIP=0 restores the per-beat sweep without a rebuild, and a test runs both shapes in one process to show it.
Five new tests in Tests/TickSweepSkipTests.cs; fifteen falsifications, nine red and six green. The six green ones are bumps that are redundant with a per-viewer flag; they are kept and the code says why. ViewportRectTests.Move wrote a mob's tile by assignment outside the world lock and now goes through the world's own commit.
The tier A hunt (Fable) approved with two LOWs; the fix round's Opus re-check approved with one informational LOW. Suite green both builds on the merged head, Debug 1248 and Release 1238.
The fix round lifted the reviewer's two probes into facts: a position written by a second thread mid-sweep is swept on the next beat, and the sweep records the captured count rather than a live read. Follow-up: a per-region key is what would make this pay on a walking spread, and it waits for a hold to say what the map-wide key is worth.
The World.cs comments and two earlier packets still say the beat is 600 ms; it is 333 ms. Both translations are in the PR's report.
The launcher builds the runtimeconfig the branch says, and dumps no frames by default (PR #268, tooling)
Scripts/Serve.ps1 deletes the Server and LoginServer genruntimeconfig cache before the build, so Server.runtimeconfig.json is regenerated for the branch under it. The incremental build regenerated that file when a template was added and not when one was removed, so a hold that switched branches could run the base binary under the head's GC policy with nothing on screen to say so. Reproduced in four builds in the PR evidence, before and after.
Both generated batch files now set P1998_LOG_WIRE=0 unless the launching shell sets it or -WireDump asks. The effective value goes into run/session.json as wire_dump and onto the "Started from" line when the dump is on; -Status and -Stop refuse -WireDump. The server itself defaults the dump on, and a clean-shell pair had written 479,110 dump lines in five minutes.
A default start differs from before by the cache removal and one set line per batch file. No server source is touched, so run-server.bat and a hand-started pair are unaffected. Nothing about how the game plays changes.
Two smokes on port base 9000, default and -WireDump, and a four-case batch-file diff are in the evidence. Tier C, coordinator-verified; fork CI builds and tests the tree and does not exercise the launcher.
Load run: the GC memory budget, two Release pairs (branch pr/gc-conserve-memory, closed on its numbers; laptop)
System.GC.ConserveMemory=5 pinned through Server/runtimeconfig.template.json, with a -conserve5 suffix on gcMode in status.json so a running server names the level it got. One commit, fork CI green on it.
The first pair, four minutes at 400 walkers, collected exactly once per arm during the login ramp and never again, so the setting had nothing to act on; the two memory curves matched to 3 MB. All three of its cost bands fired (CPU, ms per step and slow-beat share), and the same evidence showed the rise was in the regen phase with no collection in either span.
The twenty-minute pair, the same script with 200 walk rounds instead of 28, collected 27 times per arm. The setting changed no memory reading by more than 3% in either direction (working-set peak 1.027 of base, heap peak 1.018, committed at last GC 1.019) and cost no tick time (CPU 0.939, ms per accepted step 0.948, slow-beat share +0.55 points). No measured cost, no measured benefit. The branch was closed; it comes back only if the VPS ever runs in a small-memory regime.
Standing facts from the long pair: at 400 walkers on this 15.7 GB laptop the working set saw-tooths between about 330 MB and 1.3 GB, and ConserveMemory acts only at a collection.
The first run-to-run spread figures for a Release hold. The four-minute pair's base arm beside the earlier Release head run of the same length: 3% on CPU, 10% on ms per step, 31% on working-set peak. The twenty-minute base beside the four-minute base: 0.5% to 16.5% on the non-memory readings (CPU 1.057, ms per step 1.052, beat total 1.153, viewports 1.165); memory readings are not comparable across span lengths. A hold reading inside those bands is noise.
Found on the way: the stale-runtimeconfig defect PR #268 fixes. The long pair's first base launch came up with the head's GC policy and was stopped before any client ran.
The tick's map snapshot rents its buffers (branch pr/reconcile-snapshot-pool, dropped on its numbers)
PR #266's follow-up on the tick side: the reconcile's per-map snapshot allocated 9,313 B a beat at 400 players on one map and now allocates 0. Correct and tested (Debug 1244 and Release 1234 green), fork CI green, merge tree clean.
Worth three orders of magnitude less than #266: the tick runs three times a second where the walk step ran about 1,200, so it removes about 28 KB/s at the 333 ms beat (the report's 15.5 KB/s assumes 600 ms). Wall time is a wash at the load shape and slightly slower on many small maps, +9.66% Release on two hundred maps of two players, 0.017 ms/s in absolute terms, because the rent, clear and return are paid per map per beat.
Dropped without a review spent. The branch stays on the fork at 7d6771e.
Measurement: what a hold on World._lock costs (branch pr/lock-contention, no product change; laptop, harness in the review evidence)
400 walkers and the real tick on one map, 700 accepted steps a second: the lock is held 1.8% of the time in Release (2.5% Debug), the tick holds 1.5 to 1.8% of that, and the view snapshot is 0.04 ms/s of it. The tick's own wait is 0.0002 ms/s. A walker finds the lock taken on about 7% of steps and waits 0 µs at the median and 35 µs at p99 Release; turning the tick off entirely changes the walkers' wait by 0.11 ms/s Debug and by nothing Release can resolve.
The whole snapshot family's ceiling is about 0.06 ms/s of walker wait, 0.05% of the session threads' step time. This answers PR #266's follow-up 4: no more snapshot slices.
The durable rule: wait grows with the square of a hold's length, not with the lock's total held time. The tick is 1.8% of the held time but about 40% of the wait, because its hold is one 0.28 ms block where a walker's are about 12 µs each. Tenfolding the tick's hold to 2.7 ms multiplied the walkers' wait by 16 (Debug, 0.28 to 4.40 ms/s). A 0.3 ms hold is free; a 3 ms hold is not.
Two laptop artefacts every in-process timing has to control for: CPU idle states (step median 598 µs against 131 µs with one core held awake) and the 15.6 ms Windows timer, which made all 400 walker threads wake on the same boundaries (57% contended fraction against 5% with timeBeginPeriod(1)).
The harness drove the tick at 600 ms; at the configured 333 ms its per-second tick figures scale by 1.8, so the tick's hold is about 0.5 ms/s Release rather than 0.29. Fourteen runs with a foreign build starting or finishing inside them were discarded and repeated; the discard compares process identities, not counts.
The rule goes into docs/common/Locking.md as the next docs slice.
Dev process (records since the workflow's review records began)
Workflow.ps1 metrics over the review records: 117 merged PRs with records. Tier A 34 merged, median 2 review rounds, 0 escaped defects. Tier B 43, median 2 rounds, 2 escaped. Tier C 40, median 1 round, 1 escaped.
Six artefact reads this window, one per report; the HIGHs filed were an uncaptured environment quote, a one-tick slow-beat count difference, a memory-range transcription error, unsaved suite counts, a spread argument applied to memory readings, and a falsification roll-up count. None moved a conclusion; every correction is appended to its report.
Server: a fury that lapses while you are logged off still takes its vita, the status tick stops allocating on idle beats, the server publishes where players and mobs stand and how fast they walk, the viewport rect tests go lazy, a load script whose bots stand apart, six load holds, and a profile of what one step costs
A Chung Ryong fury that lapses while you are logged off still takes its vita (PR #259, refs the PR #255 review)
Rule change, player-visible: if you log out with Chung Ryong's Rage up and come back after it has lapsed, the tier's vita price is charged on your first beat back, with the usual "Chung Ryong's rage leaves you drained." Until now a fury that ran out while you were offline was never restored and never charged, so the drain could be dodged by logging out, contrary to what the code comment claimed.
Nothing else changes: renewing before the fury lapses still costs nothing, a fury that lapses while you are online drains exactly as before, a plain fury is saved and restored as before, and a fury stripped by death, dispel or Cleanse still owes nothing.
How: the fury is saved and restored while its tier is set, not only while its deadline is ahead. The drain fires from the same regen-tick line it always fired from, within one world tick of arrival, after the character has been drawn.
Five new tests cover both logout windows, the no-double-charge case, a short absence and a plain fury; three were run red against reverts. Debug 1181 and Release 1171 green.
Review found nothing above LOW: it checked every reader of the two fury fields for a second visible effect (none), confirmed no save path can persist drained health with an unpaid tier, and confirmed the one-beat window in which death or dispel voids the drain is the same as before.
TickSleep and TickPoison allocate nothing on the beat that does nothing (PR #260, refs PR #254)
The world tick calls both on every player every beat, and since PR #254 both return on their first line for almost everyone. Each still built a lambda's closure on the way in, before that check could run: 64 bytes per player per beat, 25,600 bytes a beat at 400 players, about 77 KB a second. Now 0, measured in Debug and in Release, with 400 real sessions, median of 7 runs of 200 beats. The only allocation left in that phase is the online roster's list, 3,360 bytes a beat.
The three broadcasts moved into two small private methods called from the same lines under the same session lock; same frames, same arguments, same order. No visible change. The rare path (a player actually asleep or venomed) is unchanged to two decimals per frame.
A bare block around the captured local is not enough: in Release the compiler merges a lone closure scope back to method entry and the allocation comes back (measured, 12,800 bytes a beat); in Debug it does not. The code comment records the Release case; the review found it should say "Release" and that is queued for the next comment pass.
Three new tests: the idle sweep allocates 0 bytes over 8,000 calls (red at 256,000 bytes with one capture restored), and a sleeping or venomed player's beat still sends a watching neighbour the same drowse flash and the same health bar. Release build and test are what CI runs.
The 400-session test roster moved to its own map id after a neighbouring 0-byte broadcast test once read 520 bytes with the roster on the shared home map; the figure was not saved and stands as a hypothesis.
run/viewport.json: where players and mobs actually stand, and how much of them a viewer sees (PR #261, refs the viewport profile)
The viewport sweep is about 80% of the beat at 400 players. A per-map spatial index would cut it by the share of a map's entities outside a viewer's rect, and every number we have for that share comes from the load script, which stands all 400 bots in one 16x16 room where 99.75% of them are in view and an index saves nothing.
The server now writes run/viewport.json beside run/status.json on the same 10 second timer: every online player's map and tile, every live mob's tile on maps with players, and per map the mean share of players and mobs inside a viewer's drawn 19x17 and strict 17x15 rects, with the per-viewer min and max. status.json is unchanged. Nothing in the game changes; no packet, no game state, nothing on the tick.
Under the world lock the survey only copies positions into arrays, measured at 0.02 ms steady state with 400 players and 365 mobs on the laptop under load (first sample 1.2 ms, JIT); the rect tests and the JSON run outside the lock on the status thread, about 4.7 ms Debug and 1.6 ms Release per document. The review re-measured the lock hold in a quiet window before the merge.
Five tests: the published fractions equal hand-computed ones for a fixture with a peer and a mob in each of the three cases (red when the two pads are swapped), a corner viewer gets the clamped camera the client draws, the survey refuses to compute inside the lock (red when moved inside), an empty world renders a valid document, 400 players render on one map.
An offline script in the review evidence recomputes the fractions from the raw tiles and prints a 16x16-cell occupancy grid. The number the spatial-index decision waits for comes from running this on the deployment for a day.
The viewport rect tests run only on the branches that read them (PR #262, refs the PR #258 review)
The peer decision computed both rect tests (strict 17x15 and drawn 19x17) for every entity before it looked at the drawn-state store, though an undrawn entity only ever needs the strict one and a drawn entity that has left only needs the drawn one; the mob sweep did the same with its strict test. Each test now runs only where its answer is read. On the clump fixture that is 0.19 µs per viewer per beat off the peer sweep and 0.12 µs off the mob sweep in Release, about 0.13 ms a beat at 400 players; by exact call count it is worth about ten times more on a map where the viewport actually cuts (0.59 fewer tests per peer and 0.90 per mob, per beat).
No visible change: a 36-run decision table drives the new code and a copy of the old one through every combination of drawn state and position for peers and for mobs, and the profile's traffic rows send the same frames and the same bytes in both. Four of the table's branches were broken on purpose and went red; the review broke two of them again itself.
One thing the table cannot see: making the undrawn branch read the wide rect is invisible to every test, because the send pass re-checks the strict rect and rolls the store back. It is harmless today and is on the list for any future change that trusts the decide pass.
The Release before-and-after was taken in one quiet window with the two arms interleaved and their ranges not overlapping; the Debug in-process A/B agrees in 15 of 16 interleaves; the Release in-process A/B did not settle on a laptop that drifted after the suites, and the PR says so.
A load script whose bots stand apart (tooling, no PR; refs the viewport profile)
The clump load script's 400 bots log in on map 4711 "Welcome", a 16x16 room smaller than the viewport, which is why every hold so far measured an in-view share of 0.9975. Two generated variants now exist under reviews/load-spread-evidence: 400 bots on a jittered 11-tile grid across Wilderness (1002), and 50 bots on each of eight maps; same accounts, same walk-say-walk loop, same 28 iterations, plus one @warp line per bot.
Placement is computed from the server's own pass and wall rules, the warp table and the edge-clamped viewport anchor, all cited to their lines; no two bots share a tile, and rows are spaced so the five-step walks cannot collide.
@warp is tester-tier; the launcher's -Testers list grants it to the 400 accounts, so no server change was needed.
Smokes of 25 and 100 bots on a Debug pair landed every bot on its map (25/25 and 92/100 on the exact tile); the measured drawn-rect share was 0.0016 and 0.0038 against the clump's 0.9975. The eight off-by-a-tile bots were wandering passive creatures refusing a step (12 refusals in 1,000 east and west steps: ten eastward, two westward).
The 400-bot hold with this script is next, once the survey is deployed so the hold can read run/viewport.json too.
Load run: the clump script against the spread script, four 400-bot holds on one head (Debug, laptop; refs #261, the viewport profile)
The clump script's bots stand on one 16-tile row of a 16x16 room and cannot move at all: every one of their 112,000 walk steps was refused. The spread script warps the same 400 bots onto an 11-tile grid across Wilderness and they walk every beat. Same head, same accounts, same loop.
The new run/viewport.json read exactly what the scripts were built to produce: 1.000 of peers in view on the clump, 0.0045 on the spread, both inside the bands set before the runs. An index would skip 99.6% of the peer tests on the spread map and none on the clump.
The viewport phase read 29.7 and 30.5 ms/s on the two clump holds and 83.5 and 83.4 on the two spread holds; the whole beat 37 to 40 against 132 to 143 ms/s; slow beats 0.2 to 1.9% against 11.6 to 12.7%. The hypothesis that a full scan costs the same wherever the bots stand is refuted.
Why is not yet attributable: the spread map carries 386 mobs against the clump room's 6, the spread population walks while the clump cannot, and chat reaches 399 listeners on the clump against one on the spread. Two more holds are running to separate those: the same spread standing still, and the same walk on a map with no creatures.
Found on the way: bot positions persist between runs, so a clump hold run after a spread hold silently measures a spread unless the bots are warped back first. One hold was voided for that and rerun after a reset.
Load run: the same spread standing still, and the same walk on a map with no creatures (Debug, laptop; refs #261)
Two more 400-bot holds on the same head, each removing one of the previous run's confounds. Standing still on the same spread tiles, the viewport phase read 37.5 ms/s against 83.4 walking, so about 46 of the spread's 53 ms/s over the clump is the walking. On a 100x100 map with no creatures at all, walking, it read 94.6, so removing the 386 mobs did not make the sweep cheaper.
Both in-view fractions landed inside their bands (0.0044 on Wilderness, 0.0309 on the smaller map), every bot finished on its exact tile on the creature-free map, and the "moves" phase turned out to be mob movement, not player walking, which corrects a sentence in the previous run's report.
The viewport survey reports each map's accepted step rate beside its in-view fraction (PR #263, refs the walk-path profile)
run/viewport.json's per-map entries gain steps: the running total of accepted player steps on that map since the process started. Two documents give the rate. A refused step is not counted: it runs no reconcile, and every clump hold on record refused all 112,000 of its steps. status.json is unchanged; nothing a player sees changes.
One long per map, incremented on the branch that commits the position inside the world lock the step already holds; read by the survey in the same acquisition it already takes. No new lock.
One new test drives real walk packets and reads the published document: five accepted steps count 5, a blocked step and an off-map step still 5, five more 10, a step on another map counts only there; red when the increment moves above the refusal.
Review found nothing in the PR; it did find that the drawn-peer set is not re-added after an appearance refresh, a morph or a revert (despawn then redraw, both map-wide), so for up to a beat a client can hold a peer the set does not. That matters to the move-gating slice now in flight, which carries it.
Profile: what one player step costs (400 players, 386 mobs, one map, laptop; refs the six load holds)
Every accepted step runs on the walker's own thread: a move broadcast to every session on the map, a snapshot of the map under the world lock, then the peer, mob and item sweeps under the walker's view lock. A refused step sends one frame and runs none of it, which is why the clump holds measured a population paying for nothing.
Per accepted step: 196 µs Debug, 113 µs Release. The move broadcast alone is 114 / 54 µs, 399 frames, with no viewport test anywhere on its path: at a spread population about 397 of them go to clients that cannot see the walker, 140 KB of a step's 159 KB of allocation, 169 MB/s at 1,200 steps a second. For scale, one player's share of a whole tick sweep is 22 / 6 µs.
The tick does not mainly wait on walkers: with 400 sessions stepping three times a second its sweep gets 14% (Debug) to 19% (Release) dearer, and the view-lock wait sampled in it is under 1%. That refutes the earlier reading of the hold's movement share, and the profile says so.
The spatial index repriced for both paths: an upper bound of 63 ms/s Debug and 20 ms/s Release at 1,200 steps a second and 0.5% in view, about 60% of it on the walk path the earlier tick-only profile never counted.
Next, tier A and running: gate the peer move and turn frames on what the recipient has been drawn, exactly as the mob ones already are. The only frames that may disappear are moves and turns for a peer the client does not hold; anything else visible is a stop.
Two order-sensitive tests made honest, one guard order, and four comment corrections (PR #265, refs the PR 260 to 262 reviews)
The test that guards the broadcast helper's allocation asserted zero bytes, which also asserted that a shared array pool still had a buffer on hand; a big test roster elsewhere in a run empties that pool and took the test red twice on good code, once on PR 261's own check. It now asserts under one byte per call, which a pool re-rent passes and both regressions it exists for miss by three orders of magnitude, re-run red at 3,864 and 1,032 bytes a call.
The test suite's console capture gate, which stops two tests fighting over the console and blaming each other, now has a test of its own; it existed already and was only ever discovered through unrelated CI reds. Five call sites use it.
The viewport survey refuses a caller that already holds the world lock before it does any work, not after.
Three comments corrected: the closure-scope trap in the status tick is a Release behaviour, the test roster's pool mechanism is labelled a hypothesis, and a comment no longer claims the peer move broadcast is unconditional, which PR 264 changes.
No behaviour change; the only production edit is the two-line guard reorder.
The walk step's map snapshot rents its two arrays instead of building them (PR #266, refs the walk-path profile, PR #253)
Every accepted player step took a full-map snapshot under the world lock and built it with LINQ: 16.6 KB and 6.8 µs of lock-held time in Release, about 1,200 times a second on a walking population, in the lock the tick's own snapshot queues behind. The broadcast helper solved this shape in PR #253 with a pooled rent filled by a plain loop, wiped and returned in a finally; this is the same treatment for the other snapshot the step takes.
Measured with both shapes interleaved in one process, 400 players and 386 mobs: the snapshot goes from 6.8 to 2.7 µs and from 16.6 KB to zero in Release (Debug 11.4 to 9.9 µs), about 5 ms a second of lock-held time and 20 MB a second back at 1,200 steps a second. The whole step's allocation drops by exactly the two arrays in both builds.
Same peers, same mobs, same order, same packets: the old path is still in the tree and a test compares the two element for element; a returned buffer is proven to hold no reference, and the sweeps are proven to read only the filled prefix. Three tests, each red on its falsification. The review added a use-after-return probe that was first shown able to fail and then green over 3,000 real steps against a concurrent tick and a same-thread pool poisoner.
Not touched: the map-entry snapshot, which hands its arrays out of the method, and the tick's own per-map snapshot, which is the same shape on the tick side and the next slice in this family.
A player's step and turn go only to the clients that hold them (PR #264, refs the walk-path profile)
A player's walk and turn packets now go only to the clients that have actually been shown that player, which is the rule the monster movement has always followed. The one class of frame that no longer goes out is a move or turn for a player the client does not hold; anyone who can see you still gets every step exactly as before, and a 44-step test walk across a viewer's screen and back produces identical frame streams with and without the gate once the walker's own moves are filtered.
On the 400-bot walking load test on the Wilderness (Debug, laptop): the game process went from 451% of one core to 52%, slow-send warnings from 23,696 to 400, slow beats from 29% to 2%, while accepting more steps a second than before. On the 482 bot-and-neighbour pairs seen on both runs, no bot was staler than before and 474 were exactly where the server had them (before: none).
Two gaps closed on the way, both found by review: a peer redrawn by a morph, a revert or an equip change is now recorded as drawn, and the resync on death or revive marks drawn peers as suspect instead of forgetting them, so neither path can lose a neighbour's next step. The second also sends the despawn the old code leaked when such a peer walked off the edge of the screen.
The tier A review re-broke three falsifications, ran a 400-recipient concurrency control with zero lost frames for held entities, the test client suite 33 of 33, and found the resync gap; the fix round added two tests that fail with the old code, and an independent re-check approved the delta.
P1998_GATE_PEER_MOVES=0 turns the gate off without a rebuild. Stated cost: on a map where nearly everyone already sees everyone, the probe costs 34 ns per recipient in Release and drops nothing; break-even is about 85% of recipients holding the walker, against 0.4% measured on the spread.
Load run: the move gate measured in Release, and a launcher switch to start a Release pair (Debug versus Release, laptop; refs #264)
Scripts\Serve.ps1 gains -Configuration Debug|Release. Release is the build the live server runs. With the switch absent or set to Debug the generated start files are byte-identical to before, so every existing hold and script is untouched; the console titles, the Started from line and run\session.json name the configuration only when it is not the default. -Status and -Stop never look at it. This is PR #267.
The same 400-bot Wilderness walk as the PR #264 pair, built Release, one run each: ungated 429% of one core, gated 27%, a factor of 15.7 against Debug's 8.65. Per accepted step 7.1 ms to 0.39 ms while the gated server accepted 710 steps a second against 603; slow-send warnings 5,843 to 400; slow beats 6.7% to 2.5%.
Every one of the 571 neighbours the gated bots held was on the tile the server had it on, and on the 471 bot-and-neighbour pairs seen on both runs none was staler than before.
Debug against Release on the ungated side: within 5% on whole-process CPU, about three times apart on the per-tick phase timings, because the ungated server is saturated in both builds and only the tick thread's wall-clock phases show the build. From now on every hold figure says which build it is.
The game server's frame hex dump is on by default and every hold on record had it off only because the operator's shell said so. A run started from a clean shell wrote 479,110 dump lines in five minutes and was thrown out; a second was thrown out because another build overlapped it. The hold driver now records any foreign build process every ten seconds, and both kept runs are clean on every sample. Whether a dev pair should default the dump off is an open decision.
Server: the map broadcast helpers stop allocating, the tick reads the sleep and poison timers before taking each session lock, natural regen returns to the original game's global clock, and the peer sweep decides straight off the snapshot array
The map broadcast helpers stop allocating: one call to 400 players goes from 45,361 bytes to 0 (PR #253, refs #250)
The two helpers every mob move, turn, swing, effect and player action goes through to reach the players on a map allocated a closure for every peer on every call, 96 bytes each, plus a LINQ copy of the player list. At 400 players on a map that was 45,361 bytes and 12.9 µs per call. The per-peer send now goes through the same static-lambda isolation the tick got in #250, and the player-list copy is rented from the shared array pool and handed back wiped, so a call allocates nothing of its own and takes 2.7 µs.
How often the tick calls them at the hold's load: the row B phase rate bounds the mob moves and turns at 21.6 broadcasts a beat on the hold's own map; the real mob AI driven offline over the Wilderness spawn set gives 33.6 a beat scaled to 305 mobs, above that bound because open terrain lets mobs step more freely. At 400 players and three beats a second the bound is 2.9 MB/s, 23% of the 13 MB/s the 400-bot hold measured server-wide; the open-map figure would be 35%. Either way it is now zero.
The map-entry and map-leave loops that draw a newcomer for the room and erase a leaver took the same one-line change; they run once per warp or login, not per beat, and carry no numbers.
No behaviour change: the same players get the same frames in the same order, the exclusion and area filters are unchanged, and a player whose send throws is still skipped and logged by the same line while everyone behind them still receives. The pooled copy is taken under the world lock and iterated outside it exactly as the old copy was, and it is wiped before it goes back so a returned buffer cannot keep logged-out players alive.
Five new tests pin the isolation for both helpers, the exact zero over 2,000 real calls, the order and exclusion, and the empty-map edge. Each was broken three ways and restored: without the catch, with the closure back, with the LINQ back.
Found while reviewing, not fixed here: no test drives the area box's inclusive edges through the helper itself, and the buffer wipe has no test of its own; both are one-fact follow-ups.
The world tick reads the sleep and poison timers before taking each player's session lock: the status phase goes from 6.61 to 0.23 ms per second at 400 players (PR #254, refs #250)
Every beat the tick called the sleep and poison steps on every online player, and each step took the player's session lock before finding the timer was zero. At 400 players that was 800 lock entries a beat to do nothing, and the tick waited behind any player whose own thread held its lock mid-send. Both steps now read the timer first and take the lock only when there is something to do; the decision they act on is still the one made under the lock.
Measured on a matched pair of 400-player load runs from the same master, same machine, same client, back to back, both passing with no failures: the status phase fell from 6.61 ms of every second to 0.23, and the slow-beat count was 21 of 542 on both sides. On the before run the phase led 7 of the 21 slow beats with a 268 ms worst case; on the after run it never appeared on one.
Profiled with 400 real sessions: the loop cost 311 ns per player per beat with nothing in its way and 5,013 ns with ten other threads holding their own session locks; after the change the ten-thread figure is 69 ns.
The whole beat did not get shorter: the time moved into the viewport sweep and the regen pass, and the review confirmed the regen step still takes every player's lock every beat the same way. That is the next slice; a repeat pair comes before any claim about the beat as a whole.
One deliberate behaviour change: a sleep or poison applied in the instant between the tick's read and its return is picked up one beat later, 333 ms, never lost; the first redraw and first venom tick are already scheduled a whole interval ahead.
Three new tests pin it: the no-op path returns while another thread holds the lock, an afflicted player is still ticked under the lock with the 1 HP venom floor and both lapses, and a hold applied on another thread is seen by the tick thread. The review added its own control through the whole tick and ran the full test client suite, 33 of 33 scripts passing.
Natural regen is back on the original game's global 25 second clock, and the tick reads four values before taking each player's session lock for it: the regen phase goes from 7.07 to 1.66 ms per second at 400 players (PR #255, refs #254)
Natural health and mana regeneration used to run on a private 25 second timer per player that restarted every time you dropped below full, so the first tick after a hit was always a full 25 seconds away and anyone being chipped at faster than that never regenerated at all. Now there is one world clock, as in the original game: everyone below full regenerates in the same moment every 25 seconds, taking damage does not restart anything, and two people in a fight tick together. The amount healed, the stat scaling and the Purple Potion bonus are unchanged. This was chosen after today's behaviour, a per-player rolling clock, was shown beside the original's global one.
The world tick no longer takes every player's session lock every beat to find out they have nothing to regenerate, no buff fading, no fury wearing off and no mail to re-check: it reads four values first and only takes the lock when one says yes. At 400 players the regen phase went from 7.07 ms of every second to 1.66, and it stopped appearing on slow beats at all; on the before run it led a third of them with a 127 ms worst case.
Measured on a matched pair of 400-player load runs, same machine, same client, back to back, both passing: slow beats fell from 18 in 572 ticks to 4 in 511, 3.15% to 0.78%, and the whole beat went from 60.7 ms of every second to 50.5. The previous pair moved the beat total the other way, so the next load run repeats this one before anyone claims the tick as a whole got faster.
Profiled before and after with 400 real sessions: the loop cost 281 ns per player per beat with nothing in its way and 5,013 ns with ten other threads holding their own session locks; after the change the ten-thread figure is 45 ns. Of the 281, 174 was the lock and 65 the equipment-totals sum, which the common path no longer reaches. Once every 76 beats the global regen beat does what the old code did every beat, 0.11 ms at 400 players below full.
Seven new tests: the no-op path returns while another thread holds the lock, each of the four due conditions still does its job, the expiry hint is exact after every buff writer, and two players below full regenerate on the same beat with the boundary at the 76th beat and every 75th after. The review broke each of them and its own control through the whole tick, and ran the full test client suite, 33 of 33 scripts passing.
The protocol document's regen paragraph now describes the world clock instead of the deleted per-player timer.
Load run: three matched pairs of 400-player holds, PR #254's merge against current master (refs #255)
Six holds run alternating before/after/before/after/before/after, back to back on an idle laptop with 7.2 to 7.4 GB free at every launch, same client and same script every time, all six passing with at least 170 seconds at 400 players each.
The regen fix holds up on every pair: the regen phase read 5.21, 7.25 and 6.07 ms of every second on the three before runs and 1.57, 2.00 and 1.75 on the three after runs, so every after run is below half of every before run. It led 17 of the before runs' 59 slow beats with a 160 ms worst case, and 1 of the after runs' 45 with a 71 ms worst case.
The whole beat came out "in between" by the reading fixed before the runs: 55.4, 63.9 and 59.4 ms/s before against 58.7, 52.8 and 56.9 after. The averages are 59.6 and 56.1, but the ranges overlap and one of the three pairs moved the other way, so nothing is claimed about the tick as a whole; six holds cannot separate a 3 ms/s effect from a 6 to 9 ms/s spread on each side.
Unchanged and worth recording: the viewport sweep is 47.6 ms/s before and 47.3 after, is now 84% of the beat, and is named on every slow beat on all six holds; the status phase holds at about 0.3 ms/s on both sides; slow beats were 3.14 to 4.49% before and 2.40 to 3.52% after; one gen0 and one gen1 collection and no gen2 on every run; working set peaked at 1,166 to 1,180 MB everywhere; SLOW SEND went from 1,885 to 2,528 on average on identical traffic and is still a threshold count with no rate behind it.
Profile: the viewport sweep, 84% of the beat, attributed line by line (refs #245, #246, #255)
The viewport sweep was profiled offline at 400 players and 305 mobs on one map: 13.9 ms a beat with nothing moving and 15.0 ms with the bots' walk traffic and real frames, against 14.8 to 16.8 ms measured on the twelve live holds this week. The phase is the scan itself, 704 in-view decisions per viewer per beat, not lock contention: 400 threads taking viewport locks at the bots' own walk rate cost the sweep 0.2%, and all the sweep's lock acquisitions together are about 1%.
The peer sweep is 65% of it and the mob sweep 31%. Inside the peer sweep, the pass that copies each peer's id and tile out before the lock, touching 399 other sessions' memory per viewer, is 29% of the whole phase on its own; the in-view rectangle tests are 23% and the drawn-set lookups 20%.
The sweep is linear in players plus mobs per player, so the phase is quadratic in players: 66.8 ns per extra player and 31.6 ns per extra mob per viewer per beat. Measured points: 400 players with 200 mobs 11.8 ms a beat, 800 players with 305 mobs 50.5 ms a beat, which is 15% of a 333 ms beat on this hardware.
The steady sweep's only garbage is two enumerators per viewer per beat, 25.6 KB a beat; the send path allocates about 36 bytes for every byte it puts on the wire.
Six cut candidates were priced. By microseconds removed per file touched, first is carrying the peer's id in the snapshot so the copy pass touches no other session (4.1 ms a beat at 400 players, two files, no decision changed), second is a cheaper drawn set (2.8 ms, four files); a spatial grid removes up to 86% only where players are spread over a map, and nothing at all when they stand in one clump, which is what the load script does; a "nothing moved" skip saves nothing while every bot walks every beat.
The peer sweep decides straight off the snapshot array: the viewports phase goes from 47.6 to 31.9 ms per second at 400 players (PR #256, refs #245, #246, #255)
The viewport tick's peer sweep used to walk the player list through an interface and copy every player into a scratch buffer before deciding, because an interface enumeration could run arbitrary code and nothing arbitrary may run under a viewport lock. The world-lock snapshot now carries each player's id with their tile, the sweep takes the plain array and decides straight off it under its one lock acquisition, and the copy pass is gone.
Profiled with 400 real sessions and 305 mobs: the peer sweep went from 21.4 to 14.4 µs per viewer per beat in Debug and 7.2 to 4.3 in Release, 2.8 ms and 1.2 ms off a beat at 400 players, with the untouched mob sweep drifting the other way as a control. The sweep also stops allocating the 32 bytes per viewer per beat its interface enumerator cost.
On one matched pair of 400-player load runs the viewports phase fell from 47.6 to 31.9 ms of every second and the whole beat from 55.1 to 40.6. Slow beats were 16 of 511 before and 18 of 541 after; the review split them by cause and found the two extra were beats the scheduler started late, not beats the sweep made slow, and the work-driven count fell from 11 to 8.
No behaviour change: same players, same ids, same tiles, same order, same frames after the same revalidation. The two tests that inject a walk mid-sweep now do it through a named test seam fired at the same moment the enumerator used to fire, and a new test pins the decision order against the array.
The earlier step on the same branch, carrying the id in the snapshot on its own, was measured before the sweep change and found to save almost nothing by itself; the profile's attribution had counted the whole copy pass, and the report says so. That correction is in the profile report too.
The mob and floor-item sweeps decide off concrete arrays (PR #257, refs #256)
The mob sweep walked its list through an interface inside the viewport lock, kept harmless only by every caller happening to pass an array. Both sweeps now take plain arrays, so nothing but the server's own decision runs inside a player's viewport lock, and the 32 bytes per player per beat each enumerator allocated is gone: 12.8 KB a beat at 400 players.
The time saving is the enumerator alone, measured honestly at about 0.7 µs per player per beat in Release and half a microsecond to a microsecond in Debug, roughly 0.3 ms off a beat at 400 players. There was no copy pass here to remove, unlike the peer sweep in #256.
No behaviour change: same mobs and items, same order, same frames; a new test pins the decision order against the array. Four stale comments from the previous review are corrected, including two test comments that named a falsification that has done nothing since #245.
Found on the way: one CI test can fail by reading another test's console output through a shared tap, and a profile can silently measure the wrong clone when two workers share a scratch directory; both are recorded as follow-ups with the fix named.
One drawn-state entry per entity replaces the shown/edge set pair: 17 to 19% off the viewport sweeps in Release (PR #258, refs #256, #257)
Every player's client kept two hash sets per entity kind to remember what it had drawn and what had drifted into the overdraw band, and the sweep probed both for every entity every beat. The pair was already a three-state machine written across two sets; one dictionary now says which state an entity is in, in one lookup, writing only on a transition. Hash probes per entity per beat go from 1.93 to 1.00.
Measured with 400 viewers and 305 mobs, three interleaved before/after runs, each output printing which build it loaded: in Release the peer sweep fell 19%, the mob sweep 17%, and the whole viewport phase 17%, about 0.5 ms a beat at 400 players. In Debug only the mob sweep moved, about 0.6 µs per viewer per beat; the rest is inside run-to-run spread. The deployed server is a Release build; the laptop's load runs are Debug, so no load run was taken for this one.
No visible change: the same spawns, despawns and re-draws on the same beats. A new test drives the old two-set representation as a reference oracle and the new store side by side through every transition and fails if they ever disagree; the review added its own walk derived from the old code, including a broadcast landing on a banded entity and a resync, and both agreed.
The store's resident size is about 1.7 KB per entity kind per player larger at 400 drawn entities, by layout arithmetic; per-beat allocation is unchanged.
Two parallel workers on this and PR #257 briefly measured each other's clone through a shared scratch directory; the affected runs were caught by their own printed paths, discarded and re-taken, and every packet now names a private scratch directory.
Server: the mob and item sweeps send outside the viewport lock, the phase totals become a rate, the tick stops allocating per player, two test seams, the db collection runs in parallel again, and a matched load run
The mob, item and peer sweeps decide under one viewport lock and send after releasing it (PR #246, refs #245)
The mob viewport sweep built and sent every packet with the player's viewport lock held, so on a beat that sends (walking onto a map, a step that crosses many creatures, a spawn wave) the lock was held for the whole sweep and that player's own walk waited behind it. It now decides under the lock and sends after letting go, the shape the peer sweep got in PR #245.
Profiled 400 real sessions, 305 mobs and 50 floor items on one map, base and head back to back: on a beat where 40 mobs leave and 40 enter, the longest unbroken hold went from 64.7 µs to 14.6 µs per player per beat in Debug and 39.3 µs to 5.4 µs in Release, at 0 bytes added. That beat costs more CPU, since each deferred frame re-takes the lock for a moment to check it is still right; which side wins at 400 players needs a load run, which this slice did not include.
The ground-item sweep took the viewport lock once per item; with 50 items on the floor that was 5.5 µs per player per beat and is now 2.0 µs. The load map has no floor items, so no load run has seen this path.
A deferred frame is dropped if another reconcile contradicted it while it waited, and a deferred draw is also re-checked against where the player is standing when it goes out: if you stepped while a creature's draw was queued, the draw used to go out for a tile off the edge of the screen, the client discarded it, and the server believed it had drawn something you could not see. When a draw can no longer be sent, the server puts its records back exactly as it found them: a never-drawn creature is forgotten, and a creature already on screen that drifted to the edge stays tracked so it is still removed when it leaves.
The same edge-of-screen hole was in the player-to-player sweep from #245, so it is fixed here too.
Eight new tests pin the packet sequences both sweeps produce and five parked-send schedules for creatures, players and floor items; every existing viewport test is unchanged and green.
Every beat adds its phases to a running total published in run/status.json, and the 1 ms floor flake is closed (PR #248, refs #243 and #245)
run/status.json now carries elapsedMs, beatMs and a phaseMs object with a running total for every phase of the world tick, keyed by the same names the slow-tick log line prints. Two samples give a phase's milliseconds per second of wall clock for every beat, slow or healthy, instead of a floor over the logged beats.
The totals are summed in raw stopwatch ticks and converted once at render, so a phase costing a fraction of a millisecond a beat still adds up instead of reading zero forever.
Cost on the tick path is one add per phase mark: 0.65 ns in Debug and 0.25 ns in Release per mark, 15 ns and 6 ns a beat. The two SLOW TICK lines and the existing status fields are unchanged.
The tick-phase test that went red once on CI now pays for the phase it asserts through a test-only hook, so a fast runner cannot empty the line; with zero creatures seeded it still names its phase on a 3 ms beat.
A test-only in-process hook for the logger's formatted line (PR #249, refs #239)
The logger gained a test-only hook that hands each formatted line to a test in process, so a log assertion no longer has to attach the log file, shut the logger down and read the file back. In production the hook is null and costs one static null check per line, below what a million-call bench could resolve.
The GM override tests moved onto it and out of the log-file test group, so they sit next to the world state they pin and run in parallel with everything else.
The tests that hold SQLite's write lock hold it on their own file, and the db test collection runs in parallel again (PR #247, refs #242 and #244)
The four tests that deliberately hold the database write lock now do it on a database file of their own, through an additive seam beside the production path: the production connection, path and initialization do exactly what they did.
The db test collection no longer runs alone; on a busy laptop the Debug suite went from about 1 m 40 s to about 1 m 32 s. A probe saw 2 of 20 concurrent saves fail with "database is locked" the old way and 0 of 20, three times, the new way.
Two stale comments fixed: a failed unconditional save does re-dirty the character, and a contended write gives up in 5 seconds rather than the 30 one doc still described.
The tick's per-player steps stop allocating 1,600 delegates a beat: 459 to 139 bytes per player per beat at 400 players (PR #250, refs #245)
The world tick allocated a throwaway delegate for every player for every step it runs on them (sleep, poison, regen, the viewport reconcile), purely to keep each player's step isolated so one player's bug cannot abandon the beat. At 400 players that is 1,600 objects a beat, 128 KB a beat, 385 KB a second. The isolation now takes the player as an argument instead of capturing them, and allocates nothing.
Profiled 400 real sessions through the real tick before and after, Debug and Release: the back half of a beat went from 459 to 139 bytes per player per beat, and the garbage collector's cheapest generation runs 2.7 fewer times a minute at that load. The viewport reconcile's 104 bytes per viewer per beat is exactly the figure the #245 pass measured and left as a follow-up.
No behaviour change: same steps, same players, same order, same log line when something throws. Two new tests pin it, one by arming a real player's outbound to throw mid-sweep and checking the players after it are still drawn.
Found while measuring, not fixed here: the map broadcast helper allocates 45 KB per call with 400 players on the map, one closure per peer, and the tick calls it for every creature that moves.
Load run: a matched pair of 400-player holds, master against PR #245's merge (refs #246, #248, #250)
Two 400-player holds on the laptop, back to back, same client, same script, same machine state: `b8d776e` (PR #245's merge) and master at `f473152` (with #246, #248 and #250). Both passed with 0 failures, about 180 to 190 seconds at 400 players each, 6 to 7 GB free at launch.
Counted slow-beat share over the same 541 ticks at 400 players: 2.96% on `b8d776e` and 3.51% on master, 16 slow beats against 19. That is inside the noise of one pair, so nothing is claimed about the direction; settling it needs repeat pairs.
First reading of the new phase totals as a rate: the tick body costs 61.3 ms per second of wall clock at 400 players, of which the viewport sweep is 46.0 ms/s (74.9%) and the status phase 7.8 ms/s (12.7%); lock-wait is 0.08 ms/s. The slow-beat sample on the same run understated the sweep 6.2-fold (7.4 ms/s as a floor against the 46.0 ms/s rate), so the per-phase floors in earlier load notes are not comparable to these rates.
The status phase is the baseline for the next slice: 7.8 ms/s, leading a third of the slow beats, worst beat 295 ms.
New fact, cause unknown: the server's working set peaked at about 1.15 GB on both sides, against about 400 MB in the three earlier holds from the same 159 MB at launch. It is the same on both sides so it is not a change since `b8d776e`; whether it is server growth since 2026-09-17 or the newer test client's traffic is the next measurement.
The status document says how much memory the process is holding and why (PR #251, refs #248)
`run/status.json` now publishes the game process's working set, the GC's heap and committed bytes, the machine's available memory and high-load threshold, the gen0, gen1 and gen2 collection counts, and the GC mode, all read on the status thread, so a load run can say whether a large working set is the program holding memory or the GC holding on to it.
The status thread pays about 2.2 ms per 10 s render for the working-set read, none of it on the world tick.
Why: the 2026-09-18 load run peaked at a 1.14 GB working set against about 400 MB in the three holds before it. The evidence on disk points at the GC's budget under 6 to 7 GB of free memory rather than retention: the growth stops flat under continuing work, the fall shows no pause, the server runs server GC with 16 heaps and no limits, and the test client change between the holds did not execute. One hold with these fields settles it; until then it is a hypothesis.
Load run, row A: the 1.1 GB working set is the GC's budget, not the program's memory (measurement on master a923f93; refs #251)
One 400-player hold on master with the new memory fields, same client and script as the earlier pair, 6.2 GB free at launch. The managed heap is a sawtooth: 36 MB to 1,054 MB and back to 76 MB, then to 701 MB and back to 51 MB. Only three gen0, two gen1 and one gen2 collection ran in 250 seconds while the server allocated 12.5 MB a second.
The 200 MB drop the earlier runs saw part-way through is a full gen2 collection, which also returned 209 MB of committed pages to the OS.
Reading, the coordinator's after the fact because the pre-decided one assumed a heap level the run does not have: after each collection the process holds 36 to 76 MB of managed bytes against a working set of 870 to 1,160 MB, and that floor never rises. A live set of a fiftieth of the working set is not retention; with 16 server-GC heaps, no limits and 6 GB free, the GC is letting garbage accumulate between collections. No defect. A second hold with 4 GB of memory occupied beforehand is the controlled confirmation and has not run.
The run reproduced the earlier pair's beat on identical traffic (viewports 45.4 ms/s against 46.0, lock-wait 0 at every percentile), so the memory fields cost nothing on the world tick.
One flaw found in the new fields: the committed-bytes figure is a snapshot from the last collection, not the current moment, so it reads 0 until the first collection runs. Fix queued.
Load run, row B: the working set follows the machine's free memory, so the 1.1 GB was the GC's budget (measurement on master a923f93, same server and client as row A)
The row A hold repeated with one change: an idle process held 4.7 GB so free memory at launch was 2.3 GB instead of 6.2 GB. The game process peaked at a 482 MB working set instead of 1,163 MB and settled at 400 MB; the GC ran 11 ephemeral collections in the span instead of 2; the heap floor after each collection stayed at 15 to 83 MB and never rose.
The server allocated at the same rate either way, 13 MB/s against 12.5, on identical traffic and the same 124,000 completed bot actions. The big working set was the .NET server GC's budget on a laptop with memory to spare, not memory the server was holding. Retention is refuted for this workload.
Collecting five times as often cost the world tick nothing measurable: viewports 45.64 ms/s against 45.36, lock-wait 0 ms at every percentile, one 34 ms GC pause on one slow beat. The slow-beat share fell from 4.24% to 1.96%, which one hold each way is not enough to call a gain.
The committed-bytes field flaw is confirmed a second time: it read 0 for the first 40 seconds and held one value across five samples while the working set grew 90 MB. The fix is on its own branch.
Open for a decision: whether the deployment target should pin the GC with a conserve setting or heap limit, since on this evidence a 480 MB process costs nothing over a 1.16 GB one; and the status phase halved from 7.03 to 3.71 ms/s between the two holds, plausibly the working-set read costing more on a bigger process, untested.
The committed-bytes status field is named and documented for the last collection (PR #252, refs #251)
The server's committed-memory status field is renamed `gcCommittedAtLastGcMb`, because that is what it is: the .NET API it comes from reports the last garbage collection, so the number reads 0 until the first collection runs and then holds one value until the next one. It was documented as a live reading, which both load runs showed it is not.
The test that guarded those fields asserted two things the runtime does not guarantee, that the heap never exceeds the committed figure and that the committed figure is above zero, both of which the load runs showed false. It now forces a collection first and checks what is true after one, against the counter read directly.
Server: the tick body at 400 players in three cuts, two CI reds made deterministic, one content override, and a five-second database timeout
The viewport reconcile takes the viewer's rect once a sweep: 66 slow beats to 38 at 400 players (PR #240, load-run slice 2)
Ran the 400-player bot load twice, before and after, on the laptop: 400 bots, 305 mobs, one 16x16 map, about 190 seconds at 400 players each time.
The phase line from PR #233 named the culprit: viewports led 65 of the 66 slow beats, at 119 ms of a 129 ms beat, lock-wait 0 in every line.
The viewport reconcile was rebuilding each player's camera anchor inside every entity test, twice per entity: about 560,000 recomputations a beat for 800 distinct answers. It now works the rectangle out once per sweep and re-checks a per-session move counter at each decision, so no decision uses a rect older than the viewer's tile.
Slow beats at 400 players went from 66 to 38 (11.5% of beats to 6.6%) and server CPU from 58.3% to 55.4% of one core. The phase's median across the beats still slow only moved 119 to 111 ms, so viewports is still the target.
Six new tests pin the view rectangle's edges, its overdraw band, the peer half, and that a delayed sweep cannot undo a completed walk's draw.
Two CI-only reds made deterministic (PR #242)
Fixed a test that could fail claiming the server never logged a slow-send warning when it had: two test classes racing for the process's console could take the capture away from each other. Console capture is now one exclusive helper.
Fixed a test that could report "@ban refuses a name with no character row" when the real cause was another test holding the database's write lock for 30 seconds; that collection now runs on its own and the setup save checks itself.
Retire the 68 per-table content path overrides; P1998_GAME_DATA is the one content override (PR #241, refs #35 and #42)
The per-file content variables (P1998_MOBS, P1998_SPELLS, P1998_MAP_CELLS and the rest, 72 names with the four Lua inputs) are no longer honoured; P1998_GAME_DATA relocates the whole content directory. This is a behaviour change.
A retired name that is still set is ignored and produces one startup warning naming it and P1998_GAME_DATA; unset names are silent and absent from the banner.
TableSpec no longer carries an environment-variable name; every content path, including the login server's era files, resolves under the game-data directory.
Tests that pointed one table elsewhere through the environment now use in-process seams.
The generated game-data/README.md table drops its override column and docs/common/Configuration.md says the per-file names are retired; two hand-written docs that still told readers to set one are corrected.
Split the viewports phase at its lock and count the ticks (PR #243)
The slow-tick phase line now separates the world-lock snapshot at the top of the viewport reconcile from the per-player sweep: (3.0) view snapshot and (3) viewports.
run/status.json publishes ticks and slowTicks, so the slow-beat share is counted rather than derived from the wall clock.
One 400-player hold on the merged head: the lock block was under a millisecond on 52 of 55 slow beats and never above 4 ms; the sweep alone led 50 of 55 at a median 92 ms, 85% of slow-beat work. The next cut is in the sweep's per-viewer bookkeeping, not the lock.
The counted share (9.15%) matched the derived one (9.13%), confirming every earlier "% of beats" figure.
The database connection's command timeout matches its busy timeout: a contended write fails in five seconds, not thirty (PR #244, refs #242)
A database write blocked by another writer now gives up after five seconds instead of thirty, so a save that hits contention no longer parks a player's session thread for half a minute before retrying.
The two timeouts that decide that (SQLite's busy_timeout and the provider's command timeout) come from one constant, so they cannot silently disagree again.
A failed save now re-dirties the session on every path; unconditional saves (spellbook, legend, profile) used to be lost on failure. The change-profile reply reports whether the write succeeded instead of always saying "saved".
The test suite is about 28 seconds faster, most of what the CI-flake fix cost.
The peer sweep takes one viewport lock instead of 400: slow beats 5.09% to 1.29% at 400 players (PR #245)
The viewport reconcile took each player's viewport lock once per peer, so at 400 players on one map the tick made 160,000 lock acquire/release pairs a beat to decide that nothing had changed. It now decides for every peer under one acquisition and sends afterwards, re-checking each deferred frame against the drawn sets just before it goes out.
Profiled 400 real sessions and 305 mobs on one map first to pick the cut: the per-peer acquisition was 74% of the peer sweep, and most of that was the thread-local depth counter the lock-order assert needs, which the Debug build the server runs does not inline.
400-player load runs on the laptop, same script, about 170 to 180 seconds at 400 players: slow beats went from 26 in 511 (5.09%) to 7 in 541 (1.29%), a slow beat's work from 146 ms to 104 ms at the median against a 333 ms heartbeat, server CPU from 62.5% to 55.6% of one core.
No behaviour change on the wire. Two real clients, one standing and one walking across the edge of the other's screen 20 times, produce an identical despawn/redraw sequence on the new code and the old; five new tests pin the packet sequence one sweep produces and that a delayed sweep can never overtake a walk that finished while it waited.
Still open in the same phase: the sweep leads 6 of the 7 beats that are still slow, and the reconcile has no way yet to skip a player with nothing to reconcile.
Server: every environment knob declared once, the last hand-prefixed log lines
2026-09-16 · Essorcal · Server PRs #235, #237, #236, #238, #239; closes #42, #199 and #57
ServerConfig, part 1: one declaration per environment knob, a generated reference, and a startup banner (PR #235, #42 part 1)
Shared/ServerConfig.cs declares every knob once: name, area, type, default and doc string; 43 knobs across twelve files, resolved once per process and immutable after.
Knob types own the parse rules, so a bad value keeps the declared default and logs a !! line naming the variable, the value and what is in force; nothing becomes 0.
docs/common/Configuration.md is generated from the declarations and a test fails CI when it drifts or a knob is missing.
The four gameplay values that were env vars (P1998_HIT_CRIT, P1998_HEAL_CRIT, P1998_DEATH_DELAY_MS, P1998_SPELLBOOK_CAP) are rows in ServerTuning.csv with their old defaults and rules, and reload without a restart; a retired variable still set warns loudly at startup.
Both processes print the effective configuration at startup, grouped by area, each knob tagged environment or default.
Booleans have one meaning across nine knobs; four sites that split between == "1" and != "0" now trim whitespace, and the wire-log knob stays exact-match so a padded " 1" cannot switch password logging on.
The last nine hand-prefixed diagnostics route through Log.Warn and Log.Error (PR #237, closes #199)
The four REJECT lines in the shared acceptor go through Log.Warn; a new string-only Log.Error takes the five fatal and startup lines that had no exception to carry.
Same text, same level, byte for byte; a grep for hand-written !! prefixes through Log.Info now returns nothing.
Two facts pin the exact line text and level for both entry points.
ServerConfig, part 2: the remaining inline reads (PR #236, #42 part 2)
Every remaining environment read goes through ServerConfig: the world heartbeat, the two health probes, the status document, the staff rosters, the map and SObj path overrides, the bind address, the handshake budget, the two outbound write bounds, the trust-on-first-use switch, and the ten connection-admission caps; 68 knobs declared, up from 43.
The ten abuse-control caps (P1998_LOGIN_MAXCONN, P1998_GAME_PERIP and the rest) are documented for the first time; their names used to be built from a prefix at runtime.
Numeric values in force are unchanged for every raw value; rejected values now warn. Both loopback exemptions and the trust switch match their old rules row for row.
Five knobs that were re-read on every use are resolved once at startup; the staff roster files are still re-read on @reload.
HostAddress.FromEnvironment is gone; ConnGuard selects a door's knobs instead of interpolating a prefix, and a typo prefix throws.
61 new facts pin every converted knob's default and both sides of every clamp edge; the live banner and a real per-IP cap rejection were verified on a pair.
The only environment reads left outside ServerConfig are the 68 per-table content path overrides, which belong to #35.
One online-player resolver, one free-neighbour search, and the mute state in the moderation file (PR #238, #57 part 2)
One resolver does the "find this online player or say why not" lookup for six commands; every refusal keeps the exact wording and channel it had.
The horse you set down when dismounting and the tile a respawning creature lands on share one free-tile search, with each caller's own walk order, predicates and fallback kept.
Live mute state moved next to the @mute and @unmute commands that write it, with the first tests for mute.
Fixed a test map-id collision that made a spawn test fail only in full runs.
A GmOverrides class with a @toggles readout and actor logging, and the RE probes in Session.Probes.cs (PR #239, #57 part 3, closes #57)
Staff overrides (@clip, @peace, @anywarp, @showwarps, the three melee sfx slots, the mob swing pose and the @wmpos dots) are one GmOverrides class; every read site is still a plain field load.
New GM command @toggles: every override you are under in one readout, plus the @clock and weather pins.
Every override change now logs who made it, what it was and what it became; the old at map (x,y) suffix and @mobact's faced= note are gone from those lines. Nothing on the wire changed.
The ~25 reverse-engineering probe commands moved into Server/Session.Probes.cs, 42 members verbatim; no command changed name, tier, arguments or help text.
@where, @bring, @rez, @click and the group invite share the one "not online" gate with the six sites from part 2, wording and channel unchanged.
Server: the first bot load run, wire logs that obey their switch, and a slow tick that names its phase
2026-09-13 · Essorcal · Server PRs #234, #233; load run of 2026-09-12 against master b5bb486
The first bot load run, 25 to 400 players
Debug build of master on the laptop, loopback, test client at main 101b679; every bot spawns on map 4711 and loops walk east, say "hi", walk west, wait.
The 25-player gate passed. 25 to 200 players: zero slow ticks, zero slow sends, server CPU under 8 percent of one core.
400 players on one map: the first stall. 26 slow ticks, tick work 132 ms median and 297 ms worst against the 333 ms heartbeat, lock-wait zero every time, so it is the tick body, not contention. Outbound queues hit 326 frames per client; server CPU 61 percent of one core.
Below 400 the test client was the limit: with its JSON event stream on it aborts at 200 players on the five-second chat-echo deadline; with it off the same script carries 200 in 90 s and 400 in 171 s.
Wire dump off saved 14 to 31 percent of log volume because the outbound hex dump was not behind the switch; at 400 players the logger dropped 663,928 lines and rolled a 64 MB file twice. PR #234 closes that.
Every bot spawns on one tile and steps onto occupied tiles are refused, so nearly every walk was refused: chat broadcast was exercised, movement broadcast was not.
Nine follow-up slices proposed: server (outbound hex gate, the tick body at 400, bounding the outbound queue, a thread-pool floor), test client (affordable event stream, latency in the checkpoint file, the verb deadline as a knob, a load-shaped script), harness (a log budget).
Wire packet logs honor their switch, and drain facts pin the teardown gate (PR #234)
The outbound packet dump and the town-list dump are whole-line gated on the wire switch, matching the inbound side; a fact proves OFF emits nothing, ON still does, and the packets are sent either way.
All nine Log.Hex sites enumerated and classified.
New facts: sixteen concurrent fire-and-forget closes start one drain, and an awaited drain still waits for the bytes.
Routine login teardown logs type and message without a stack; unexpected login read failures log with one.
Fix round: the login handoff line stays at Info; only the token bytes follow the wire switch.
Suite 977/977, 0 warnings.
A slow tick says which phase spent the work (PR #233)
Every phase of Tick and FlushTick is timed, and a slow beat prints a second line naming the phases most expensive first, with the rest under "other".
Emitted only on beats that already print SLOW TICK; that line is unchanged. No phase reordered, nothing moved across the lock, about 600 ns and 0 bytes per healthy beat.
Fix round: the phase-sum guard pins the invariant instead of a fixed window.
Two new tests: a 4,000-creature beat and a healthy beat.
Count slow beats with SLOW TICK: work; a bare SLOW TICK grep now matches two lines per beat.
Suite 976/976, 0 warnings.
Reviews and process
Both PRs were tier C with coordinator records, one fix round each.
PR #235, ServerConfig part 1 of #42, is open in a tier A review; promoted from B because it changes how every knob is read and flips whitespace-padded booleans at four call sites.
Metrics to date: 21 tier A merged at a median of one round, 21 tier B at two, 29 tier C at one; one escaped defect, tier C.
Still queued
The load run's slices, starting with the tick body at 400 players; the log sink seam for tests; the login read loop's single-clause catch.
Test client team: the event stream, verb deadline and load-shaped script slices; pin the suite's test-client ref for server reviews.
Server: one outbound for both processes, the last four peer sends under the recipient's lock, the status probe's polite close
2026-09-11 · Essorcal and brian · Server PRs #231, #230, #232; direct pushes 9e9e170, 3ffb44d
The Geomancers' five elemental orbs are obtainable (direct push 9e9e170, brian)
The Forgotten Past quest line is in: five NPC abilities, the quest script, its registry entry and tests, with the deferred-work and quest-registry docs updated.
The Rogue's White Moon Axe can be earned at Buya's Maso (direct push 3ffb44d, brian)
The White Moon Axe armour quest is in with its NPC ability rows, quest script and tests; the Armor-Quests doc and the NpcAbilities table gain its rows.
The whisper, the ban's kick and the two announcements run under the recipient's monitor (PR #231, follow-up of PR #225)
The 1:1 whisper, @ban's kick, @announce and the restart countdown now take the receiving player's session lock first, with the reads that decide whether they hear it inside the same section. Same lines, same people.
A ban now saves the player at the moment it lands, the way a kick already did.
The restart schedule's own lock is never held while it speaks to players, on any of its paths; deliberately moving the announcements inside that lock deadlocks the new race test in ten seconds.
The whisper's echo can no longer carry the click-marker text a GM parks in a player's name for one packet.
Five new tests, including six hundred rounds of the restart countdown against a GM booking and cancelling restarts.
Fix round: the ban notice text, which opens the moderation database, is computed before the section rather than inside it.
One outbound for both processes: TcpOutbound moves to Shared and the login copy is retired (PR #230, follow-up of PR #226)
The game server and the login server share one outbound queue type in Shared; the login-only copy from #226 is deleted and the test project no longer references the login executable.
Each channel keeps its own numbers through an options record: the game's 2048-frame queue with no per-write bound and a fire-and-forget drain, the login's 64 frames, ten-second write bound and awaited drain.
The login server now logs the same SLOW SEND warning the game always had, well before it drops a slow connection; that is the only behaviour change on either channel.
The game's writer runs the same statements it ran before; the review diffed the no-bound path against the old loop statement by statement.
Twelve existing outbound tests run against the one type; one new test covers the login warning.
Review: the live suite lost five scripts, all traced to the shared test client sitting on its team's in-flight fixture changes, with zero outbound lines on either channel and every login redirect paired.
The status probe half-closes and drains before the socket closes (PR #232, PR #228 review F2)
A status probe whose request arrived in pieces over a slow link used to get the connection reset instead of the status JSON even though the server log said it had answered. The send side is now shut down after the write and the rest of the request is read off before the socket closes.
The handshake watchdog still bounds a caller that never hangs up; no new timer.
Two new loopback tests: the split request now receives the answer, and a never-closing caller ends at the watchdog. Removing the drain alone reproduces the reset, so the drain is the load-bearing half.
Reviews and process
The tier A review of #230 ran on Opus (Fable usage exhausted for the week); it built and ran the merge tree, re-broke four falsifications, ran its own loopback controls with both profiles and a live silent-peer probe against a pair, and approved with five LOWs and no code change.
The tier B review of #231 (Opus) caught a database read inside the banned player's lock; fixed in one hoisted line.
The probe half-close slice was handed from Codex to Opus after the Codex session never started; the same packet was used unchanged.
Review metrics to date: 20 tier A PRs merged at a median of one review round, 20 tier B at two, 26 tier C at one; no escaped defects recorded.
Still queued
The bot load run with its 25-bot gate, waiting for an idle-machine window.
A fact for the outbound's one-shot drain gate; a log sink seam for tests instead of the process-global console tap; the login read loop's single-clause catch (all low).
For the test client team: pin the suite's test-client ref for server reviews; the five #230 suite failures are theirs to confirm on their branch.
Server: four more slices from the queue: peer sends under the recipient's lock, the login channel's outbound queue, Shared's doc XML and the bind line, the split status probe
Nine more cross-session status-text sends run under the recipient's monitor (PR #225, follow-up of PR #219)
Clan chat, subpath chat, the parcel notice, @carnage, @bring, the mentorship culmination, the Sage world shout, spell flavour lines and @kick now take the receiving player's session lock before speaking to them, with the reads that decide whether they hear it inside the same section.
@carnage could lose a win when two staff scored the same player in the same instant; it cannot now.
@kick saves, tells and drops the target in one section on that target.
The two chat loops read each recipient's toggles a moment later than before, so a player flipping clan or subpath chat inside that window is answered against the fresher read.
Nine new facts, one per site, each proved to fail with its fix removed.
Still to convert, one later slice: the 1:1 whisper delivery, the @ban kick, and the server announcements including the restart ladder.
The login channel sends through a bounded queue and drops a peer that stops reading (PR #226, follow-up of the PR #213 review)
The login server no longer writes to the socket under a lock on the session's own thread; sends go through a queue of 64 frames drained by one writer task, so a client that stops reading can no longer hold the login session's read loop.
A queue that fills, or a single write that stalls past ten seconds (P1998_LOGIN_WRITE_MS), drops the connection with one warning line naming which bound tripped.
The game handoff packet is drained for up to one second before the login connection closes, so a login cannot complete halfway.
The welcome goes through the same queue; its log line now says "queued welcome".
Five new tests over real loopback sockets; the test project now references LoginServer.
Moving the game's outbound and this one into Shared as a single outbound for both processes is the stated end state, a later tier A slice.
Shared generates its doc XML, and a listener that cannot bind names the port (PR #227, PR #224 follow-up and the PR #209 review)
Shared now builds its XML documentation, so a dangling or ambiguous cref there is a build error; one ambiguous cref in the era calendar now names its overload.
A server that cannot claim its listening port logs one error naming the bind address, the port and the socket error before exiting as before.
One new fact occupies an ephemeral port first and checks the line.
A status probe split inside GET is answered, not dropped; the Parse length doc names the non-0xAA case (PR #228, PR #220 review probe (e), PR #222 review F2)
A game-port status probe whose first read delivers only part of GET now waits for the rest under the existing handshake budget and is answered, instead of being dropped by the head-byte rule added in #220. One-write probes, broken prefixes and never-completing prefixes behave as before.
The frame reader's hook contract now says the one legal mutation is appending bytes the hook itself read from the stream, which is what the probe wait does.
The TkPacket.Parse docs now say the length output is zero unless the head byte is 0xAA and three bytes are present.
Four new loopback facts through the real read loop.
Reviews and process
Tier B reviews of #225 and #228 (Opus) approved with LOW findings only; #225's review also caught a false comment about the kick's lock nesting and a title that overclaimed, both corrected in a text-only fix round.
The tier B review of #226 (Opus) found the PR's own new tests flaky (one red on the upstream Linux runner, one red on any run narrower than the full suite) and promoted it to tier A; a fix round made both deterministic, and a fresh re-check session ran the class twenty times green before the merge. The production code held on every probe in both rounds.
#228 was briefed as tier C and promoted to tier B by the coordinator on sight because its change reads the socket inside a reader hook.
Still queued
The bot load run with its 25-bot gate, waiting for an idle-machine window.
One outbound for both processes in Shared (tier A).
The whisper delivery, the @ban kick and the announcements under the recipient's monitor (tier B).
A half-close before Close on the status-probe path so a request tail arriving after exactly the fourth byte still gets its answer (low).
Review metrics at close
Merged with a record: 19 tier A, 19 tier B, 25 tier C; median review rounds 1, 2, 1; escaped defects recorded: 0, 0, 0.
Server: the shared frame reader drops peers that stop framing, party notifications under the member's lock, the concurrency tests wait on progress
Party notifications and the invite's dead-target check run under the member's monitor (PR #219, follow-up of the PR #210 review)
Party join, leave, kick and disband notices are delivered inside the receiving player's own session lock.
Group chat ("!!") reads each recipient's ignore list and delivers the line in one locked step on that recipient.
Inviting a dead player is decided under that player's session lock; the refusal wording and the order the refusals are tried in (full, then dead, then refuse) are unchanged.
Inside the invite's race window a target who dies or revives between the old check and the new one is now answered against the fresher read.
The party roster gate stays a leaf lock; broadcasts never hold it.
Seven new facts, including two 300-round races (leave against kick, broadcast against leave) that fail on a stall rather than a clock.
FrameReader drops a connection whose unframed buffer exceeds one maximum frame or whose head byte is not 0xAA (PR #220, follow-up 1 of the PR #213 review)
A connection whose head byte is not 0xAA after framing is dropped with one warning line naming the peer and the byte; it used to stall its inbound half forever. Frames parsed from the same read are delivered first.
The unframed buffer is capped at one maximum legal frame (65,538 bytes) before and after the handshake; it had no bound.
Both front doors get both rules through the one shared read loop; the session files did not change.
An HTTP request to the login port is now dropped on its first read instead of holding a slot for the 15 s handshake timeout. The game port's status probe is unaffected.
Drop was chosen over re-sync: the framing has no checksum or trailer and bodies are ciphered, so a forward scan can lock onto a false header.
While the head-byte rule stands the byte cap cannot fire; the cap states the bound in one place for the day re-sync is ever added. Stated in the code and the PR.
Six facts, one of them the boundary case that a maximum-length frame in 4 KB reads never trips the cap.
Protocol.md's hardening section records the two bounds.
A frame whose length field is under 2 is malformed: TryParse never throws and the reader drops the peer (PR #222, follow-up of PR #220)
A peer sending a frame whose length field is 0 or 1 is dropped with one warning line; it used to throw out of the read loop and log an error with a stack trace on the game side, one per connection.
TkPacket gains a minimum length of 2, a Parse that reports frame, need-more or malformed and never throws, and TryParse stays as a wrapper for its ten existing callers.
Frames ahead of the malformed one in the same read are delivered first; the status probe still wins.
With wire logging on, the "buffered/unframed" line prints the first 256 bytes and the total size instead of the whole buffer each read; one bad connection used to write over 2 MB of hex for a 65 KB stream.
Eleven new facts; the review ran an exhaustive parser probe over every u16 length value with no throw.
Protocol.md's framing section records the length floor.
The concurrency tests wait on progress, not on a fixed join (PR #221, follow-up of PR #216)
The progress watch from PR #216 moves to Tests/Support/StallWatch.cs and every concurrency fact uses it: 37 fixed-deadline thread joins across eight test files now fail when nothing has moved for ten seconds, not when a busy laptop runs slow.
The watch returns the instant the threads exit (bounded joins, no sleep between polls); the four per-round party races keep a 100 ms fast path.
The two barrier-paced session-actor facts get their own quiet windows (5 s and 2 s) sized from measured round times.
The two "still parked" probes in the party tests stay as they are.
Seventeen facts timed on base and head, no median slower; whole-suite wall time fell about 8 s in Debug and 12 s in Release.
The tile translation facts and the content reload fact run in one collection (PR #223)
TileTranslationTests and ContentReloadTests share one xUnit collection, so the tile facts can no longer read the one-row stub table the reload fact publishes through the process-wide tile-map environment variable.
The race was reproduced on the base with forced parallelism and is gone on the head; no other test class needed the collection.
Protocol.Tk495 generates its doc XML so the compiler checks its crefs (PR #224, PR #220 review F2)
Protocol.Tk495 now builds its XML documentation, so a dangling or ambiguous cref in the protocol project is a build error, as it already was in Server.
A deliberately broken cref went red as the proof; the XML carries 19 crefs.
Shared had one ambiguous cref (EraCalendar.PrepareReload) that would surface if Shared followed; done in PR #227.
Reviews and process
Tier A reviews of #220 (Fable) and #222 (Codex) each built and ran the trial merge, re-broke the author's falsifications, ran their own loopback socket controls and live probes against a pair, and ran the test client suite (27/27 both times, zero handshake timeouts, zero drop-rule lines).
The tier B review of #219 (Opus) found the PR's stated rationale wrong: a lost per-packet increment is a duplicate nonce the code already documents as harmless, not a torn byte. The code stood; the comments and PR body were corrected in a text-only fix round.
Two sightings of an unreproduced master Release flake (two unnamed failures once, green on rerun); neither reviewer reproduced it on a merge tree. The next red Release run keeps its transcript.
Server: one listener and one frame reader for both processes (#41 closed), party kicks under the member's monitor, two test flakes fixed
The party kick pre-check and the removal's party read run under the member's monitor (PR #210, closes #198)
A leader's kick reads the target's party inside the target's monitor and hands that party to the removal; the removal never re-reads the field.
A kick that lands after the member has left, or left and joined another group, is a no-op instead of removing them from the other group.
The roster swap and the member's field write are one critical section on the member; the straggler's disband and the broadcast stay outside it.
The un-raced paths are unchanged: same refusals in the same order, "You have left the group." and "Your group has disbanded." once each.
Three new facts: a removal handed a party the member already left does nothing, a kick racing the member's leave-and-join strands nobody, a plain kick tells the member once and the straggler once.
One #189 fact was renamed to what it now pins (nobody is kicked in it), and Party.cs's doc no longer describes the closed window as reachable.
One listener for both processes: the accept loop, NoDelay, PROXY and ConnGuard move to Shared (PR #209, #41 part 2)
The login and game servers run one shared accept loop, Shared.TkAcceptor: bind, guarded accept, TCP_NODELAY, the two-stage PROXY admission and ConnGuard, with the slot released exactly once per connection.
Each process passes its ports, its own ConnGuard and a delegate that runs one session; Server.TkListener keeps the world, the thread start-up and the shutdown flush.
The login server's accept-failure, PROXY-header and session-fault lines take the game's shape and log the exception; the session-fault line moves from Warn to Error.
login.log gets a === shutdown signal (<reason>) === line on Ctrl+C, SIGTERM and ProcessExit; it had none.
Log.Shutdown runs once per process behind a real guard; the two comments that credited one are now true.
Three socket facts over real loopback ports cover NoDelay on the accepted socket, the global cap closing a second connection, and a throwing session still releasing its slot; the ports are ephemeral.
One frame reader for both processes: Protocol.Tk495.FrameReader with the handshake timeout, and one welcome and host parser (PR #213, closes #41)
Both read loops run on Protocol.Tk495.FrameReader: the handshake watchdog, the 4 KB read, the frame assembly and the two wire dumps exist once.
The game's per-read work (the silence stamp, the HTTP status probe, the throttled autosave) and its per-frame work stay at the same points through hooks; the login channel passes none.
The connect greeting and the redirect host parser are Protocol.Tk495.Welcome and HostAddress; both servers use the one copy.
The handshake timeout line and the login read-loop error line go through Log.Warn; the text on disk is unchanged.
Nine new stream facts pin the framing, the retained partial frame, the never-re-sync rule and the watchdog's one-shot gate.
With parts 1 and 2, #41 is closed: one logger, one listener, one frame reader across both processes.
Hand-prefixed diagnostics route through Log.Warn and Log.Error; the fatal handlers carry the exception (PR #211, refs #199)
Twenty diagnostic lines that wrote their !! prefix by hand now call Log.Warn or Log.Error with the same text and level.
The fatal unhandled-exception and unobserved-task handlers in both processes log the exception object, so the stack reaches the file.
Three exception-free !!! lines stay hand-prefixed on purpose; fourteen sites in the accept and read loops moved with #41 parts 2 and 3, and four REJECT lines in TkAcceptor remain for #199.
The world-autosave thread logs one line at start-up (PR #212, closes #194)
The autosave sweep writes one Info line when its thread starts, naming the thread and the cadence, and the source-pin fact checks the text.
A mob-sourced poison no longer marks the creature's id as the PvP foe (PR #217, closes #208)
A creature's venom passes no attacker id, so a poisoned player's PvP foe is never a mob id; a player's venom still marks its caster.
Player ids start at 1 and each session's creature pool at 5,000, so the two id spaces can overlap in a long-running server; the guard is the fix, not an id check.
The foe-mark note, comment and test doc say the monitor drop is unreachable from shipped callers (PR #214, closes #207)
The note above the deferred foe mark, the comment above MarkPvpFoe and the PvpDeathOrderingTests doc now say all six shipped PvP intake sites already hold the attacker's monitor, so the entry is re-entrant and the last-statement ordering is a hardening for a future caller.
Issue #174's title and body were rewritten to match. No code changed.
The three deadlock facts in SessionActorTests fail on a stall, not on a slow machine (PR #216)
The three lock-cycle facts count completed rounds and fail only after ten seconds with no progress, with a ten-minute cap, instead of a fixed thirty-second join; two workers hit the old budget on a loaded laptop with nothing wrong in the server.
A real deadlock probe fails in ten seconds through the same helper; a slow pair that takes eighteen seconds passes.
The drop-count facts read a tally the writer's overflow notice cannot zero (PR #218)
The logger's dropped-line counters are never zeroed by the writer thread; each overflow notice reports the refusals since the previous one through a writer-private high-water mark, and says exactly what it said before.
The two drop-policy facts that read those counters no longer race the notice; the race failed one upstream run on a busy runner.
CI actions pinned to their Node.js 24 majors (PR #215, closes #201)
checkout v7, setup-dotnet v6, upload-artifact v7 and download-artifact v8; no Node.js 20 deprecation annotation on the run.
download-artifact v8 fails instead of warning on a corrupt download; nothing else in the pipeline changes.
Issues closed without a PR
#37 (World as a god object) is closed: the five extractions are merged; the "World.cs under ~1,500 lines" line was a proxy and is dropped. The mobs block is queued under #38.
Review metrics at sprint close
Merged with a record: 16 tier A, 16 tier B, 19 tier C; median review rounds 1.5, 2.0, 1.0; escaped defects recorded: 0, 0, 0.
Server: party roster as a snapshot, revives that save, the death sequence as one critical section, one logger for both processes
Party membership is an immutable snapshot swapped under a leaf lock; the invite writes the target under its monitor (PR #189, closes #167)
A member who is kicked, leaves or disconnects at the instant of a group kill is now either in that kill's payout or not; before, the kill could throw inside the exp split and pay nobody.
Two players inviting the same person in the same instant: exactly one succeeds and the other gets "They refuse to join this group." Before, both could seat them.
An invite that arrives while its own party is being disbanded forms a new party instead of being seated into the old one.
A leader's kick that lands after the member already left says nothing a second time; before, the straggler was told the group disbanded twice.
The party roster is now a read-only array replaced whole under a small leaf lock that calls into nothing, so no lock order against the world lock or a session monitor can form.
The invite handler now enters the target's session monitor, so an inviter waits while the target is inside its own packet or a disconnect flush.
Texts, exp split, cap, leader rule and wire bytes unchanged.
Ten new facts: the group-kill race, the two invite races (202/1000 and 939/1000 rounds stranded before, 0 after), the double-disband, the kicked member's own packet inside the retire window, the inviter the roster no longer holds.
Revive paths persist the restored character, so a crash after a revive does not load a ghost (PR #203, closes #196)
A revive by @rez, the Shaman or Priest revive, the NPC Rebirth, the poet Resurrect family or the Hyun Moo self-revive now writes the restored character to the store before returning; before, the row on disk stayed the corpse for up to 15 seconds.
@rez and the mythic Rebirth used on a living player as a full heal now save that heal too.
The Resurrect landing on a map that is not loaded now saves; that branch reached no save at all before.
Three store-backed facts, one per revive path.
TakeDamage marks the PvP foe after the death sequence, so the Hp write and Die() are one critical section (PR #205, closes #174)
The death sequence is now one critical section however the blow arrives: the attacker-side foe mark, which enters the attacker's monitor, moved from between the Hp write and Die() to the last statement of TakeDamage.
On every shipped path the attacker's own thread already holds its monitor, so the old position never actually dropped the victim's monitor; the move is a hardening for any future caller that does not, and the new fact pins it.
When the attacker is recorded as the victim's foe shifts by the length of the death sequence; the pet AI is the only reader and the mark lasts 15 seconds, so nothing observable changes.
Two new facts: the five-thread ordering race and the survived-hit control, both on real PvP arenas.
One logger for both processes: Shared.Core.Log, with the login channel's wire dump still off by default (PR #204, #41 part 1)
The game server's bounded-queue logger moved to Shared.Core and the login server's lock-and-console copy is deleted; both processes share one class, one format, one rotation and the non-blocking guarantee.
P1998_LOG_WIRE has one parse rule with a per-process default: on for the game server, off for the login server because its packets carry passwords. 0 is off and 1 is on in both; any other value takes the process default and warns at startup.
login.log now stamps milliseconds, opens with the same header as server.log, and its !! and !!! lines carry Warn and Error severity so they keep the queue headroom from PR #190.
The login log is flushed on Ctrl+C, SIGTERM, process exit and inside the fatal handler; a line logged after shutdown is dropped instead of throwing.
A process that never configures the logger gets the wire dump off; both entry points configure it as their first statement.
P1998_LOG_MAX_BYTES keeps both defaults, 64 MB game and 32 MB login.
Protocol.md states the one P1998_LOG_WIRE rule.
Parts 2 (shared listener) and 3 (shared frame reader) of #41 are separate PRs.
The CSV open-count fact measures the content load only, so parallel collections cannot fail it (PR #202, closes #169)
The test that proves every content CSV is opened exactly once now counts only paths under the content root, so another test class opening a temporary CSV in parallel no longer fails it. Test only.
Process, this session
Review metrics over all records to date: tier A 12 merged, median 1.5 review rounds; tier B 15 merged, median 2.0; tier C 12 merged, median 1.0; recorded escaped defects 0 in every tier.
Issues filed
#206@clearinv clears worn items but preserves cached equipment bonuses · #207 PR #205's note, MarkPvpFoe's comment, the test doc and #174 describe a monitor drop no shipped caller can make · #208 the poison tick marks a mob id as the PvP foe.
Server: the death sequence closed, log headroom for warnings, World section 5, stale names, the self-test, MapEditor's package graph
Die() reconciles peers after the save, so a revive cannot land mid-death (PR #184, closes #177)
A revive that arrives while a dying player's death sequence is still running can no longer leave the death penalty charged to a living player.
The viewport reconcile at death now runs after the save, next to the trade teardown, so the two calls that can drop the dying player's monitor are the last things Die() does.
On the dying player's own client the peer redraws now arrive after the penalty texts and the "You have been defeated" line instead of before them.
New test: a five-thread race proving the death completes before the reconcile lets another thread in.
Log keeps headroom for Warn and Error under backpressure and counts drops by level (PR #190, closes #73)
When the log queue backs up (a stuck console, a slow disk), Info lines are refused at 61,440 queued lines while Warn and Error lines are admitted up to the 65,536 hard capacity.
Lines whose text starts with the hand-written !! or !!! prefix are admitted at that level whichever entry point wrote them, so the fatal-exception and slow-tick lines keep their headroom.
The overflow notice reads dropped N info, N warn, N error instead of one total.
The file-open control line is never refused by the drop policy.
Nothing blocks the caller; the no-wait add and the hard capacity are unchanged, and the output text of every line is byte-identical.
New tests cover the admission policy, the prefix rule, the notice text and the control line, in a serialised test collection.
TkListener starts the world's threads; World keeps what they run (PR #185, #37 section 5, the last section)
The tick and autosave threads, the watchdog, the restart ladder and the status writer are started by the process host in Net.cs instead of by a method on World.
World.Start is gone; World keeps the tick body, the restart schedule and a one-shot MarkStarted guard, so a second start is still a no-op and a test can still assert a constructed World has no threads.
The source-text pin on the thread wiring moved with the threads and got stricter: World.cs must contain no thread start at all.
New test: the start guard is one-shot under a four-thread race.
World.cs is 2,520 lines; all five #37 extractions are done, the epic's ~1,500-line target is not.
Stale member names in comments, doc crefs and Protocol.md (PR #186, closes #125, #180, #158, #161)
Nine unresolved and six ambiguous XML doc references in Server point at the member that exists now.
Comments and Protocol.md lines naming World.PlayerById, AllPlayers, RegisterOnline, SaveAllPlayers, GetWeather, SetWeather and SyncClock now name the OnlineRegistry, AutoSaveLoop, WeatherService and WorldClock members.
The Protocol.md paragraph on the blocked-chaser shuffle now describes the stateless one-step random walk the server has used since 2026-08-24.
Four stale "core (see CastX)" comment headers reworded.
Server.csproj now generates XML docs with the missing-comment and missing-param families suppressed, so a dangling cref is a build error from now on; fifteen doc-XML warnings that surfaced were fixed in the same commit.
Self-test expectations reconciled with the sourced formulas; FAIL now exits nonzero (PR #191, closes #116)
--selftest passes on the current content; each expected number carries its RTK source in a comment.
A failing self-test names every failed section and exits 1 after flushing the log.
The Spark-family damage rows in spell_effects.csv match neither the RTK Lua nor the documented live probe; filed as #200 for a data decision, no row changed.
Failed-reload identity test covers every snapshot member, public or not (PR #192, closes #120)
The failed-reload test now checks all 88 content snapshot members through their facades, 24 of them non-public and previously unchecked; a member without a facade fails by name.
MapEditor no longer carries BCrypt and SQLite through Shared (PR #193, closes #126)
The CSV reader moved to a package-free Shared.Core leaf project that Shared and MapEditor both reference; namespaces unchanged.
MapEditor's Release output drops 25 files and 25.6 MB of BCrypt and SQLite assets; the server and login server outputs keep them.
Shared.Core is listed in the solution and the server build filter.
Process, this session
Every branch cut before PR #186 must build its merge tree with master before its check can be trusted; the doc-XML check caught one such branch (#190) after its fork CI was green.
The #169 CSV-count test flaked once on an upstream check and cost a rerun; its fix is in progress.
Tier A reviews found a HIGH on the party-roster PR (#189, not merged) and one MEDIUM on the logger PR; both fixed in rounds, the party one awaiting its second-session re-check.
Review metrics over all records to date: tier A 10 merged, median 1.5 review rounds; tier B 14 merged, median 2.0; tier C 10 merged, median 1.0; recorded escaped defects 0 in every tier.
Issues filed
#187 TileTranslationTests can fail a Release run on a healthy tree · #188 RecordingOutbound is not thread-safe · #194 the autosave sweep is silent on success · #195 the thread-wiring source pin's blind spots · #196 revive paths only mark dirty, the death row stays in the store until the next autosave · #197 ResyncPeers orphans a peer loitering in the drawn band · #198 the party kick pre-check and removal read another session's party outside its monitor · #199 25 hand-prefixed diagnostic lines to route onto Warn and Error · #200 Spark-family damage formula matches neither the RTK Lua nor the live probe · #201 CI actions on the deprecated Node 20 runtime · test client #54 no suite script forms a party · #55 hunt-death.txt's @clearinv leaves worn gear on · #56 hunt-level.txt's route is severed when Buya's roster materializes.
Server: four Nagnang quests, the crypts, World sections 3 and 4, group payouts, trade teardown, and a dead-code sweep
Nagnang: Mage's Spirit Stone quest (PR #137, closes #133, project1998)
Wand offers "Mage Stone" at level 55; the three prophets talk once their mouse is zapped while Vexed; the crypt spirit speaks in Oh-mudum; Wand gives the Spirit stone and the "Family to the Nangen Mages" mark.
Staff runs the "Welcome Stranger" chain: Forever branch, Path of Choice, Sacred water dropped beside The Infected, then the Poet whip and "Destroyed Nangen Evil".
Staff moved from an unrenderable map into the Poet guild.
Sword takes a green squirrel pelt and opens the Gauntlet caves by level; touching Chung Ryong's statue with nothing red or blue killed gives the Nagnang shield and the legend; "shield" to Chul forges the bonded Tall shield.
Master Dagger's full chain: the assassins, the Blue Rooster, Maro's pocket, the crow to Dae Shore, the Stardrop, Maso's scroll, then the Dagger Uniform secret and the Daggers Guild legend.
Master Dagger moved from an unrenderable map into Nagnang.
Cemetery: all nine crypts open (PR #153, closes #152, project1998)
Eight mudum crypts had no warps at all; the nine doorways are wired from the period Atlas map.
Warps: arrivals stack on an occupied tile (PR #148, closes #99)
Confirmed from play that the original game stacked arrivals; the default policy stays and the source is recorded in Sources.csv.
The chase, retreat, straight hop, dart, step commit and collision gate move into World.MobMovement; the tick calls them at the same eight places. Six tests.
World: WorldClock and WeatherService own the calendar and the weather (PR #165, #37 section 3)
The calendar, the @clock pin and the hour rollover move into WorldClock; zone overrides, per-map weather and the period sweep into WeatherService; sessions use World.Clock and World.Weather.
The lock-requiring methods assert the world lock; seven tests including the @clock and @weather reply text. World.cs is 2,702 lines, from 2,811.
World: OnlineRegistry and AutoSaveLoop own the online slots and the sweep (PR #182, #37 section 4)
The duplicate-login table and the player lookups move into OnlineRegistry; the autosave sweep, its per-player fence and the shutdown flush into AutoSaveLoop; 53 call sites use World.Online and World.AutoSave.
The sweep asserts it is not under the world lock; without that a sweep under the lock failed silently. Seven tests. World.cs is 2,542 lines.
Group kill payouts run under each member's session monitor (PR #166, closes #154)
A shared kill no longer terminates a Debug server: each member's tally and payout run under that member's monitor, and the killer's own monitor is taken for the trap and pet callers. Six tests.
@exp kill splits across the group (PR #178, closes #155)
@exp <n> kill goes through AwardKillExp: the group in range gets its share with the group totem rule; no quest credit; bare @exp is unchanged.
Trade ends when either party leaves the map or dies (PR #172, #57 part 1)
Any relocation (door, warp, @go, Leap, Ambush, @bring, revive, Resurrect on a living target) or death ends an open exchange on both sides; a confirm after that refuses instead of finalizing.
EndTrade runs under both sessions' monitors and the finalizer re-checks under the pair. Four tests.
Mob tick: wander step commits through StepMobTo; detour fields dropped (PR #181, closes #149 and #150)
The wander step uses the same commit as every other step, so an Ice Beast that wanders onto its lava now melts.
The write-only detour fields and their seven resets are gone; four stale doc references fixed.
Dead code sweep (PR #164, closes #56)
Four unreached cast handlers, the orphaned @ignore/@friend handlers, the unreachable "level" stat arm and two unused NPC abilities removed; seven stale "kept as fallback" comments and the "~90 commands" count corrected.
The NPC-speech gate tests for the real @ prefix, with a test.
Csv: a quoted "# ..." first field is a comment (PR #163, closes #131 and #157)
MapCells.csv's three quoted comment rows load as comments instead of dropped rows; the README census regenerated.
Serve.ps1: -Status remembers the last stopped pair (PR #162, closes #130)
After a -Stop on any base, -Status reports that base's ports; an explicit -PortBase still wins.
Test-Branch.ps1 reads script headers (PR #171, closes #146 and #147)
Each script's # budget: is its timeout and its # requires bots: names join the roster; -Plan prints the run without starting anything.
Issues filed
#145@kill despawn for entities it does not remove · #156 Geomancer orbs unobtainable (project1998) · #158 Protocol.md names old World clock/weather members · #159 WrapForPane collapses double spaces · #161 stale CastGateway/CastPropose comments · #167 party member list mutated across threads during a payout · #168 late payout write after teardown · #169 CSV-open census test races a parallel collection · #170 trade survives walking out of range · #173 TryStartTrade writes the target's trade unlocked · #174 MarkPvpFoe drops the victim's monitor before Die() · #175ctx:setHp(0) leaves Hp 0 with no death · #176@help pane test renders page 1 only · #177 Die() drops the victim's monitor at ResyncPeers · #179 a throwing flush clears the dirty flag · #180 stale World registry names in comments and docs · #183 FindPlayer can return the kicked session during a re-login.
Test client: bug batch, console commands and run control, persisted-state headers, task layer part 2
Console builds against current server master, clone-layout fallback, peers labelled by nameplate (test client PR #26)
The console project references Shared from the server checkout, so it builds against a server master where MapEditor/Assets.cs reads its CSV through Shared.Csv; the output lands in the test client's own build\external\, never in the server checkout.
Directory.Build.props finds the server repo at ..\NexusTK or, for a clone under C:\Repo\Project1998\, at ..\..\NexusTK; P1998_REPO and -p:P1998Repo still win.
CreatureSeen gains an optional Name; the bot card labels another player by nameplate instead of peer 1.
Server side: Test-Branch.ps1 stages every test client project it builds, including the console and build\.
Bug batch: disposal verdict, walk bound, cast-heal MaxHp, blocked-route text (test client PR #29, issues #18, #21, #22, #24; #19 investigated and closed)
A backend that fails to close no longer ends the process with an unhandled exception; the run keeps its own exit code and one stderr line names the bot and the inner cause.
walk <dir> <n> is bounded to 1..255 at parse time, including under attempt; trailing tokens and signed counts are refused with the line and the text.
expect hp max and expect hp below max added, usable in if, until and rest until (written until hp max max <n>); a bare hp condition is a parse error.
cast-heal.txt heals relative to the character's maximum and no longer writes MaxHp; it says what persisted state it still changes.
A no-route message names the tile that cut the last successful route (the route was cut at (17,78): occupied) instead of blaming the warp rule; the first-plan case keeps the old sentence, which is honest there.
#19 (HP decoded as 0 for a healthy character) closed: the decoder was right. A character that ends a run dead is persisted dead and logs in with HP 0; a Died observation right after login is the tell, and on death fires immediately for a persisted ghost.
Console slice 1: a command box, typed lines, and a REPL (test client PR #33, issue #25)
The bot console takes commands: a box under the timeline sends one script line at a time through the existing stream socket.
A typed line is a script line: same grammar, same range checks, same <alias>: prefix, all: included; a refused line is answered with the parser's own words and nothing runs.
Typed lines run between statements only: the statement boundary, a top-level wait, a REPL's idle loop; never inside a sync/all: block, whose lines run concurrently (found in review: a typed walk had interleaved a scripted one inside a block).
Every typed line is echoed to the stream as {"kind":"injected",...} before its results; a failing typed expect is answered at once and still counted at the end; a line typed after the run's summary is answered not run.
--console with no script path is a REPL: it logs in, waits for typed lines, and quit ends it (refused during a scripted run).
Messages are decoded once per whole frame and the size cap is counted in bytes (a character split across two socket reads used to come back as two replacement characters).
The bot card draws the server's over-head health bar above a creature it has seen hit.
--json with --console absent is byte-identical to main, verified against a build of main; suite 21/21; the four two-bot scripts pass with the flag on and off.
Known: --console forces JSON-line stdout (PR 14 behaviour), so there is no plain-text mode for a run with a console attached.
Suite scripts declare the persisted state they change; relative expect hp (test client PR #39, issue #31)
Every script under scripts/, scripts/pending/ and scripts/frida/ carries a PERSISTED STATE: header line naming what it leaves on the account (items, coins, cleared bags, taught spells, Will, exp, saved position, items it removes on entry).
@hp 100 is gone from the suite: death-handler.txt and item-roundtrip.txt use @rez and the hp max forms; MaxHp is the same before and after a whole suite run (151 before, 151 after, with the gold and exp deltas matching the headers).
expect hp +<n>|-<n> added in the shape of expect gold +n: the change since the last command started; the drink is asserted as -25 and the apple as +5. Relative HP expects assume no regeneration tick lands between the command and its report (a character wounded for 25 s or more can see one 2% tick; the accumulator is per login session, so the suite scripts cannot).
Console slice 2: pause, resume, step, abort, and a per-run token (test client PR #37, issue #25)
Four buttons above the command box pause, resume, step and abort a run. Pause lands at the next statement boundary, never inside a verb waiting on the server, and the run says which line it is parked on; the page lights that line. A pause the run ends before it can land says so.
Step runs exactly one statement and parks again. A parked run still takes typed lines, and a death that arrives while parked runs its handler on the way out, before the statement it was parked on.
Abort ends a run the way a failed expect does: exit 1 with aborted at line N in the failure list, the program's last statement included (aborted after line N), so a harness cannot read a stopped run as a passed one.
Every run mints a token and prints it in the fragment of the URL on stderr; the command channel refuses any message without it (console: refused, no run token); a tab without the token sees everything and can send nothing; a token from another run is refused the same way; adding the token to an open tab's URL is enough, no reload.
Every message is a JSON envelope; a script line and a control action are told apart by which field they arrive in, so nothing typed at the box can become a control action.
Refused commands reach the run's stdout log as well as the page; a connection refused twenty times is closed and told why.
Suite 21/21 with 195 expects; --json with --console absent still byte-identical to main.
fight [nearest|<id>] [heal <pct> "<spell or item>"] [flee <pct>]: swing until the target's bar empties and it despawns, healing off your own HP and giving up when told to; one note per fight (killed, fled, gone, defeated, spent).
hunt [<name>|any] until <cond> max <n>|<n>ms [heal] [flee] [loot]: pick, approach, fight, loot, repeat until the condition holds. The bound is mandatory, there is no unbounded form, and after review it reaches inside the fight and the approach: a 20 s bound ended at 20.1 s where it had run 148 s.
A death inside a hunt runs the script's on death handler and the hunt carries on. A creature the bot cannot reach is reported once per creature, not once per 100 ms poll (1,118 lines in a minute became 16).
LevelUp observation, the narrower half of LevelChanged, for a level gained; expect level <n>|+<n>|-<n>; expect killed <n>|+<n> (at least n, counted from the server's own "You defeated" line).
--checkpoint <file> [--checkpoint-ms N] rewrites one JSON document of state, counters and recent notes while a long run is going, from a scripted run or a REPL; the replace is retried so a reader holding the file cannot lose an interval; --json output is unchanged with or without the flag.
Five scripts: fight-one, fight-heal, hunt-level, hunt-death, level-up-observed. hunt-level reaches level 2 from twelve real kills, no @exp, six runs for six.
Measured: a bare-handed level-1 character cannot reach level 2 in Dirt Cave at all (3 kills and 11 deaths in 198 s; each death costs 60 exp against 25 a kill, so the bar goes backwards), which is why the script arms the bot with one weapon. Armed: 12 kills, 0 deaths, 31 to 474 s of hunting depending on the cave's respawn timer.
Measured: melee hit rate re-checked at 58.0% (360 landed of 621 swings), the same figure PR 16 measured.
Measured: kill experience inside the character's six-hour totem window is multiplied by 1.05, so the run reads +312 instead of +300 about a quarter of the time. Neither is a defect; the script asserts the level, not the number.
Only the player look (0x33) carries a name with an entity; creatures come through the creature list with none, so a named hunt matches nothing on this server and says so. Every script uses hunt any.
The suite now has 26 scripts and needs -Bots botone,bottwo,botgrind -Passes bot1pass,bot2pass,bot3pass -ScriptTimeoutSec 900: the harness's 120 s per-script backstop is shorter than a hunt (upstream #146, #147).
Process, tooling, and findings
Review tiers adopted 2026-09-06: A (two reviewers, negative controls, merged-tree build) only for locks, persistence, wire framing, process lifecycle and credentials; B (one bounded review: the acceptance list plus at most three named items); C (no reviewer; the coordinator's verification record is the gate). Written into the workflow repo, the assignment and review templates, and the brief and review skills; the open items on both repos are triaged.
Five test client clones and five run-only server checkouts (NexusTK-tc1 to tc5) so implementation, review and re-check pairs run concurrently.
Frida F2 (PR #38, issue #28: login, walk, turn and say through injected input on the real client) is in review. The first review found two blockers: the client's login screen pre-fills the name it remembers in the registry, shared with the retail client, so an unattended run can submit the wrong name; and a client spawned by the run inherits the CLI's output pipe and outlives the run, so a piped capture hangs until it is killed. Fix round pending. Never capture the Frida CLI through a pipe; redirect to files.
#20 (the CLI needs the ASP.NET Core runtime even without --console) is in progress with Codex.
Issues filed on the test client: #32 (Buya hunt: mobs respawn onto the corridor within 20 s of @kill), #34 (a script that stops with exit 3 reports its disconnect as an object-disposed exception), #35 (see #145). Upstream: #145 (@kill sends a player despawn to other views for entities it does not remove), #146 (a script should declare its own time budget to the harness), #147 (the harness should read the # requires bots: header).
Server observations, none needing a fix here: the death experience penalty is a flat fifth of the level band, clamped at zero; a world mob's HP is authoritative and does not regenerate, so a fight interrupted by the attacker's own death resumes where it left off; @exp <n> kill is eligible for the totem bonus (300 lands as 315); TK370.map has no solid cells and no objects at all; Dirt Cave's sixteen spawns are emptied by one hunt and refill on the server's timer, which is where a back-to-back run's 474 s of hunting comes from.
Server maintenance: disconnect messages and packet handling
Fix: Game-connection rejection and GM-disconnect messages can finish sending before the connection closes.
Boards, mail, action animations and inventory-removal packets now use shared packet-writing helpers and named protocol values, preserving their existing wire behavior.
Protocol documentation now reflects the verified 4.95 client mappings for item-removal messages and the Respect/Triumph emote bytes.
Removed five test cases that did not exercise server behavior; retained the packet and public API regression guards.
Alongside the disconnect-message fix, protocol maintenance preserves existing behavior. No intended changes to combat balance, items, quests or emotes.
Test client: bot console, task layer, Frida observe
2026-09-04 · Essorcal
Bot console: every bot on the real map, with stats, timeline and expect results (test client PR #14, issue #12)
--console [port] serves a loopback-only dashboard from the CLI process; default port 6174, --console 0 takes any free port and prints the URL.
One tab shows every bot on the real 4.x map art with its tile and movement trail, peers, creatures and ground items; a panel per bot with HP, MP, level, experience, gold, bag and equipment; a filtered timeline of the most recent 2,000 stream lines with ok and FAIL distinguished and a count of trimmed lines.
The map renderer is the MapEditor's tile decoder compile-linked read-only from the sibling checkout; a change to its public surface in the main repo breaks this repo's build, which the README says.
The stream is the existing --json line writer teed to a WebSocket; with the flag off the output path is unchanged and byte-identical. A 5,000-line ring replays to late tabs; each tab's queue is bounded.
A dashboard port that is held, duplicate or out of range exits 2 with one line instead of crashing the process. This was the source of the p1998-test.exe crash dialogs.
Tile art is decoded on the first browser request, not at startup; patched map cells are outlined; a reconnect draws a run divider and a page that has failed five retries marks itself stale.
The CLI now requires the ASP.NET Core 8 shared runtime on every run, with or without the flag (issue #20).
Falls back to a labelled grid when the map data or the 5.33 Tile.dat is unavailable.
Hygiene: melee-kill made deterministic, the build kept out of the server checkout, and the #101 rejection probe (test client PR #15, issues #10, #11)
melee-kill.txt had two flakes: a 58 percent hit rate over 13 swings at a 6 HP dummy, and HP bleeding across runs until the bot died. Now 28 swings and an @rez at the top; 20 of 20 passes; the header derives the count from the measured rate.
Building the test client no longer writes into the referenced server checkout (22 files before, including Shared.dll; 0 of 92 after, also against a stale tree and with a live pair), apart from NuGet's restore state on a never-restored checkout.
New script forms: attempt <verb> treats a verb's failure as data; expect disconnected asserts the server closed the socket; an expect on a dropped bot answers text and evidence forms from the look-back and fails state forms on the spot.
scripts/pending/rejection-reaches-the-wire.txt settles upstream #101: on a rejected arrival not one byte reaches the wire, 10 of 10, proved server-side by a read-loop trace; on the newer server build the text arrived 2 of 16 times, so the race is real but not deterministic; the login channel never loses its refusal because it writes synchronously and does not close; the GM-kick path delivers it 4 of 6.
@mob spawns a level 0, AC 0, 1-damage dummy, so melee numbers against it describe that dummy, not a registry mob.
Issues #21 (walk <dir> <n> has no bound) and #22 (cast-heal.txt's @hp 100 rewrites MaxHp) filed.
Task layer, part 1: guards, loops, and navigation by name (test client PR #16, issue #13)
Scripts can decide: if ... else ... end, until and while with a mandatory bound, rest until, on death ... end.
goto <x> <y> pathfinds on the current map over the server's own .map terrain and object layers and walks it; goto map <id> [<x> <y>] routes across maps through Warps.csv (4,691 rows, 462 maps reachable from Buya) and reports the route.
A refused step is re-planned around: a wanderer is retried, a wall is remembered, a level-gated warp quotes what the server said; a goto onto a warp tile is refused before stepping.
Four observations added: LevelChanged, Died, Respawned, MoveBlocked; a transition is emitted before the state write that reveals it, because the reverse order is a race a script cannot recover from.
Conditions are snapshots and expect still waits; both come from one implementation, so every existing expect count is unchanged.
A missing loop bound, a block inside sync, and an out-of-range goto tile are parse errors naming the line.
TestClient/Tasks/ holds the map reader, the warp graph, the pathfinder and the navigator, with provenance; ObjectFlagOverrides.csv is read positionally, as the server does, and the note says how many overrides applied.
Six new scripts, including two pathfinder falsifications against the real map and a one-way ledge no ground flag can express.
Acceptance is now run in both plain and --json modes: a race was invisible in plain text and reproduced 8 of 8 under --json.
Issue #24 filed (the no-route message blames the warp rule for an occupant on the approach).
Frida backend F1: attach to the real client and observe (test client PR #23, issue #17)
--backend frida attaches to or spawns the real NexusTK 4.95 client and emits the same observations as the wire backend; F1 injects no input, and every input verb is refused with an explicit F2 boundary.
Six Winsock exports are tapped through a Python sidecar that validates the client by size and hash and verifies the attached process's path; the traffic is decrypted with the test client's own cipher, so the client's crypt functions are never touched.
Every connect to the client's baked-in 127.100.10.1:2000 is rewritten in memory to the selected test server; every other destination, including the game handoff, is left alone. No binary or hosts-file change.
Frame assembly, cipher handling, protocol state and typed decoding are shared by both backends through one decoder; the wire backend's sixteen scripts are unchanged in outcome. Plaintext is direction-aware: the client's login opcode is encrypted, the server's reply with the same opcode is not.
Raw frames are emitted in both directions; typed observations and state derive from server frames only; player looks are ignored until the entry-only self id is seen.
A framing error resyncs one socket direction and no longer disconnects the session; every sidecar precondition failure reaches the CLI as one line naming the reason; a malformed sidecar line is noted and skipped.
--timeout-ms governs the wait for a human login; P1998_CLIENT accepts the executable or its directory.
Two real-client scripts (scripts/frida/observe-smoke.txt, scripts/frida/yuri-garden-observe.txt) and setup documentation; the eight verified self-walk addresses are recorded by client version for F2.
Tooling (coordination, not in either repo)
One server pair per checkout: every concurrent test client worker and reviewer runs its pair from its own run-only server checkout (NexusTK-tc1 to tc4), never from the coordinator's.
Reviewers kill processes by path-checked PID only, never by name.
Issues filed
#10, #11, #12, #13, #17: closed by the PRs above
#18 ScriptRunner.DisposeAsync throws an AggregateException nothing catches
#19 StatsSeen decoded HP 0 for a healthy character (seen once, not reproduced)
#20 the CLI requires the ASP.NET Core shared runtime even with --console absent
#24 Navigator.NoRoute blames the warp rule for an occupant on the approach
Relayed to the server repo, not filed: Serve.ps1 -Status reports the default base after a stop on another base; ObjectFlagOverrides.csv's commented-out header; quoted comment rows in MapCells.csv; Protocol.md's "baram" counts the opcode byte (the body decrypts to "aram")
Sprint 5: tick isolation, atomic content reload, a real two-sender sync
2026-09-03 · Essorcal
World.Tick: per-mob AI in MobAiTick.Step, one mob throwing no longer abandons the beat (PR #108, issue #36)
The per-mob half of the world tick (buffs, timers, pet, prey, targeting, spellcasting, Sute, chase, retaliation, wander) lives in MobAiTick.Step in Server/World.MobAiTick.cs; World.Tick is the orchestration around it.
A creature whose AI step throws is logged with its key, id and map and skipped for that beat; the other creatures, the other maps and every queued packet still go out; the skipped creature is left where the throw found it, nothing is unwound. Before, one throw dropped the whole beat.
A map whose AI sweep throws outside any single step is logged and skipped for that beat; the other maps continue.
The body moved verbatim: 640 lines, 605 identical, 35 changed in place (26 continue to return, 9 helper prefixes), same order, same Random.Shared draw sequence; tick cost measured unchanged.
MobAiTick.Step asserts it runs under the world lock in Debug builds; docs/common/Locking.md lists it with its test.
World.MapState is internal instead of private; the tick context does not hand it out.
Test seams World.TickOnceForTest (one heartbeat, no thread) and MobTickContextForTest (a per-map context with fresh queues).
Six tests in Tests/MobAiTickTests.cs: the poison tick, the blind swing and the walk-home step each queue from a direct-driven step; a step propagates a throw; a step off the lock asserts (Debug); one throwing creature does not cost the others their packets.
Content: one immutable snapshot published with one write (PR #110, issue #33)
Content.Load fills a thread-local ContentSnapshotBuilder and publishes one immutable 88-member ContentSnapshot with a single Volatile.Write; every Content.X API is a facade over it, so no call site changed.
No window in which a registry and its lookup index disagree: 10 paused reloads and 100,000 reads see 0 mismatches.
A failed reload keeps the previous registries, era calendar, door configuration and all four Lua hosts; Reload failed (previous content kept) is now true. Before, a load error could leave a mixed generation live.
The era calendar, Doors config and the four Lua hosts (spell_verbs, item_verbs, npc_dialog, mob_ai) prepare candidates during the load and commit at the publish boundary under the Lua gate, after the registry snapshot; reloaded verbs never run against the previous rows.
Reload failure tests cover a real data error partway through the load and the final publication boundary; the snapshot member count is derived by reflection.
The @reload failure log says the previous content publication remains live; the old "public tables replaced before failure" list is gone; a nested load or a registry write outside a load reports itself as a programming error.
On the loading thread, a facade read before its registry is assigned returns the builder's empty default, not the previous load's value; #35 replaces those implicit dependencies with parameters.
Migration in eight commits, one per content domain, each building clean; Server/Content.Snapshot.cs is new.
Startup load time and heap unchanged (about 205 versus 215 ms medians, 18 MB either way).
docs/common/Locking.md: the content publisher now holds the Lua gate; @reload from a GM session takes the gate's slow path.
Test client: a real two-sender sync (Essorcal/project1998-testclient PR #9, issue #7)
sync … end and all: build and encrypt every bot's first frame, park it, and release the socket writes together on a barrier from one thread per bot.
A block reports the gap it achieved between the writes: sync: released 2 sends on a barrier, gap 3.0 us [two +0.0, main +3.0].
New assertion expect gap <us>; a block that cannot be a race says why and fails the assertion instead of passing on an older block's number; a timed-out barrier reports the timeout and fails it too.
Measured on the local server: two contending steps now leave a few microseconds apart (median about 3) where they used to leave about 560 apart; two-bots-one-tile.txt now sees the winner vary run to run instead of the first-declared bot winning every time.
The winner is not the earlier writer about half the time: at this gap the server's socket read order decides the tile, not the client's write order.
New contract member IGameBackend.ArmSendGate, implemented once in GameBackendBase; nothing else in the contract changed shape.
New scripts two-bots-all-together.txt and two-bots-nested-all.txt (all: inside sync, the regression guard for a 2 s stall the review found and the round fixed).
The barrier's spin rendezvous no longer sleeps, so two writes cannot be split by milliseconds while the gate reports a clean release; the gate is disposed with its block, and the login arrival frame cannot be mistaken for a gated one.
README.md and docs/DESIGN.md say what the barrier is worth (independence from what each bot had to do, not the small number on a symmetric block), which frame is gated for verbs like attack <id> that turn first, and that every line of a gated block runs on its own thread.
melee-kill.txt is flaky independently of this change (13 swings at a 6 HP mob with a real miss chance); a follow-up.
Tooling (local, not on upstream)
worker_guard.py: the coordinator profile now judges git aimed at another clone (git -C <clone> or cd into it) by that clone's own guard mode, so subagents launched from the coordination session are held by the same rules as a desktop session in the clone.
worker_guard.py: Windows backslash paths and Git Bash /c/ paths resolve correctly; both shapes previously slipped past the "never another checkout" rule in every profile. Self-test is 68 cases.
Issues filed
#106 World.Tick: the pre-loop phases (1)-(1.7) still have no exception boundary
#107 World.Tick: the fourteen queue lists and the post-lock flush want a type and a method of their own
#109 World.Tick: a creature that throws on every beat logs a full stack per mob per beat, unthrottled, under the lock
#111 Narrow mutable collection types in the content snapshot
#112 Make EraCalendar loading observable and remove its duplicate tuning read
#113 Report rejected Lua reloads when the previous host stays live
Closed by this sprint: #33, #36, test client #7
Sprint 4: persistence, movement and mob state under the lock, the launcher
2026-09-03 · Essorcal
Character records upgrade explicitly and unreadable rows are protected (PR #96, issue #32)
Character saves carry a schema version; a blob with none is schema 0 and reaches schema 1 only through the upgrader.
Renaming or removing a character field without an upgrader step fails a test instead of silently dropping player state.
An unreadable character record refuses login with its own message, is marked on that failed load, and no save path can overwrite it afterwards.
A temporary database error at login says storage is unavailable rather than calling the record corrupt.
SQLite migrations run in order under PRAGMA user_version and are idempotent against the deployed schema.
Persistence warnings reach the server and login log files, not only the console.
Save cost: the never-overwrite check is one integer inside the transaction, not a parse of the stored record.
Two players can no longer land on one tile at once (PR #97, issue #30)
The occupancy check and the position write for a step happen inside one world-lock acquisition (World.TryMovePlayer).
Walk snap-backs write position through the world lock.
The viewport reconcile reads peer positions from a snapshot taken under the lock, so a torn coordinate pair cannot be seen.
World.PlayerAt and World.PvpGhostAt removed; their predicates live in TryMovePlayer.
New race test: 400 barrier-released rounds, exactly one winner each; the old shape fails 15 to 57 rounds.
Warp arrivals write position under the world lock (PR #102, issue #99, part 1)
Every arrival (doorway warps, scripted entrances, world map, Gateway, GM teleports) resolves its tile and writes it inside one lock acquisition (World.PlacePlayer).
@approach, @bring and @npc pick their free tile inside the same acquisition that writes it; Session.ApproachTile removed.
An ArrivalPolicy switch exists with two members that reproduce current behaviour; whether an occupied arrival tile should refuse or step aside waits on a source check (#99 stays open for that).
Lock hygiene (PR #100)
A Lua mob heal and the Lua status read now go through the world lock; the heal's arithmetic is unchanged.
The mob-AI lock guard scans MobScript.cs and SuteAi.cs and matches bare, prefixed and member-chain forms.
The autosave race test runs a fixed number of barrier-released rounds instead of a wall clock.
Mob state under the world lock, part 2 (PR #105, issue #103)
Handing an item to a mob and claiming a harvest node go through lock-owning World methods.
The harvest claim is one acquisition; the split form double-claims under a race.
The Lua hook entry asserts the world lock is not held (Debug builds).
on_healed removed from the mob script hooks: it was advertised, resolved at load, and never fired; which heal would fire it is unsourced.
Guard patterns cover the handed list, harvest claim fields, and node-named receivers.
Content.cs split by domain (PR #104, issue #34)
Server/Content.cs (5,108 lines) is now Content.cs (496) plus Content.Csv.cs, Content.Maps.cs, Content.Mobs.cs, Content.Items.cs, Content.Spells.cs, Content.Quests.cs, Content.Music.cs, Content.Tuning.cs.
Pure file moves, one commit per file; 395 declarations audited identical; loaded content identical before and after.
Dev launcher: Test-Branch.ps1 (PR #98, issue #95)
Scripts\Test-Branch.ps1 -Checkout <path> starts a server pair, runs every test client script, stops the pair, prints a per-script table; exit 0 only when all pass.
Default port base 3000; refuses when ports are held, when the checkout's own pair is running, or when the server or test client fails to build, each with its own exit code.
Ctrl+C and Ctrl+Break stop the pair cleanly; the handler is installed after validation and removed on exit.
-Bots grants tester and GM tier to every named account (default botone,bottwo).
Scripts\Serve.ps1 fix: its start path returned exit 0 after a failed build; it now returns the build's failure.
Test client PR 4: two bots in one script, melee, and the exchange window (Essorcal/project1998-testclient #4)
Every literal the runner narrows to a wire type is range-checked when the script is read: map and position, slots, hit percent, offer amount and gold, menu reply, HP, price.
# requires bots: botone bottwo lets a script name the accounts it needs; a harness reads it before starting the server.
Issues filed
#99 warp arrivals outside the lock, no destination check (part 1 landed as #102; policy half open)
#101 TcpOutbound.Close closes without draining; a rejection message may never reach the client
#103 mob state outside the lock, part 2 (closed by #105)
test client #5, #6 (closed by #8), #7 (sync race mechanism, open)
Tooling (local, not upstream)
Coordinator scripts on local-run: Board-Claim.ps1, Prep-WorkerClone.ps1 (with -Switch), issue_overlap.py; a worker guard hook; /brief, /xreview, /sprint-notes skills.
Sprint 3 wrap-up (early 2026-09-03)
PR #85: Harden Body stops a mythic's Stormstrike (#79).
PR #86: Scripts\Serve.ps1 starts, inspects and stops a stamped local server pair (#83).
PR #91: GM commands use one reply channel and one argument parser (#43).
PR #92: a second local server pair derives the game port from the login port (#84).
PR #94: -PortBase honoured; all four ports recorded in the session file (#93).
Test client PRs 2 and 3: items, dialog, expect text, stats decode; cast, equipment, shop, bank, profile card.
Ops & testing commands
2026-08-31 · Essorcal
GM & tester commands
@announce — send a server-wide system message.
@where — show a player's location; with no name, list everyone online.
@bring — teleport a player to you.
@exp — gain experience through the normal leveling path; add kill
to count it as kill experience.
@take — remove an item from your bag; takes an amount or all.
@clock — read or pin the in-game hour; totem-time windows follow it.
@peace — mobs don't notice you; anything you attack still fights back.
@npc — find an NPC by name and jump to them.
@dura — set an item's durability, for testing repairs and breakage.
True colours & the docs site
2026-08-31 · Essorcal
Content, quests & world
The Green cloak exists again: its icon pointed past the 4.95 client's art, so it drew nothing
in the bag or on the ground — also the issue with not showing up while worn. Remapped to the real
4.95 green-cloak frame and verified live. First fix from the invisible-icon pass — 91 more items
(rings, charms, orbs, diamonds, bracelets…) are identified and queued behind review on
the proposal report.
Client & UI fixes
5.33 mob colours are fixed. The colour byte is a ramp shift, not a palette
pick (RE'd from the 5.33 exe, validated against 356 NexusAtlas reference gifs): the diamond,
golden, lake winny and fire snorter horses no longer all collapse into the plain brown horse, and
sixteen era mobs whose colour byte landed on a 5.33 SUPER palette — bold piglet, hare, the
skeleton mage and warrior, ogre maletic, the tiger guardian and defender, dog cutthroat, pig
champion, hill ogre and the six armory mercenaries — wear their era colours again. The root of
the old "every mob is green" bug is gone for good.
Server & infrastructure
Server status ping: a GET on the game port returns JSON — up, players online, era date,
restart countdown, deployed version. It feeds the live status strip on this site.
The website
From two pages to the whole library: a world atlas
of all 2,025 maps, spells, items and
mobs databases carrying every fact the server actually enforces
(restrictions, effects, shops, drops, provenance, era cross-reference against NexusAtlas), and
the 4.95 Effect Table — snugglezone's animated Effect.tbl reference.
A Reports tab collects session-generated review material, and the
front page shows live server status refreshed by a scheduled probe.
Tooling & fixes wave
2026-08-28 · Essorcal
GM & tester commands
A whole testing kit for walking the world: @clip no-clip (walls, mobs and players;
per-session, always off at login; warps still work) — plus the client-side companion 5.33 testers run
alongside it.
@anywarp — pass any gated doorway: level/mark/path gates, quest-locked warps,
mythic zodiac caves, event caves, arena doors, path-hall class doors, even the quest-item tiles
(Sute's seal, the lava row — nothing gets spent). Every gate still runs and echoes the denial it
waived, so you can verify a gate while walking through it.
@showwarps — a blue pinwheel marker on every warp and scripted doorway of the map,
visible only to you, following you across maps, with a printed list of destinations (quest-locked
ones flagged). @showwarps look <frame> re-skins the markers live.
@quest / @legend — read and write the raw quest registry and legend
marks directly (most chains gate on the legend, not the stage). Companion sheet:
The Quest Registry.
@help finally paginates instead of scrolling off the pane, and
@commands gives the compact index. The whole roster lives on
The @Command Scroll.
Content, quests & world
The leviathan talisman can no longer be lost: using it doesn't eat it anymore (non-consumables
survive u in general), and it refuses to be dropped, sold or traded anywhere but the
pen — the bank still takes it.
Client & UI fixes
The examine tooltip follows the mouse now (the anchor was being read as a row index; it's a
Y pixel).
5.33 terrain: retracted four false "look-alike" object substitutions — the affected tiles draw
their real art again.
Dev & map tools
Visual Map Editor — one-window app with the real 5.33 tile art:
draft saves that never touch the shipped maps, floating minimap, marker layers for spawns/warps/NPCs
with CSV row export, player-view toggle, walkable warps, new-map creation, and an opt-in direct mode
that writes straight into game-data.
re: the map renderer decoded EPF frames and SObj records wrong — fixed, so rendered maps match
the client.
Forever Tree, mob AI & recovered maps
2026-08-26 · snugglezone
Content, quests & world
Bon-Hwa + Forever Tree area — wired the immortal at the Forever Tree: weapon enchant ("My
Weapon," enchants a class weapon to your mark's tier for 200M exp), Shadow Stats past 130
(100M/point on the per-mark/class cap table), and the Il/Ee/Sam-san spike + Sam-san blood tiers
added to the bonded-item set.
Ravens now cast Peck (but the blind effect isn't working).
Alignment quests — the shrine/summit NPCs (join/un-align catechism) via
AlignmentQuest.cs + NPC wiring.
Leviathan quest — should be working for real!
Added maps recovered from RTK — map batch 5800–5996: Dark Forest + instance rooms
(Nagnang/Benitnath cluster).
Combat, spells & mob AI
Mob AI overhaul — aggressive mobs now notice players across the full drawn viewport (+1 tile)
instead of a short square, chase with a full-screen leash (16), and fall into RTK's random "nothing
worked" flail instead of a sideways shuffle when no forward step is open.
Mob stepping also refuses warp tiles.
Rage/Cunning fixed?
Login & accounts
Character creation returns to the main menu.
5.x password change works.
Armor quests & alliances
2026-08-23 · snugglezone
Blessed by the stars + Armor quests added — some of the Sun variants aren't finished due to
dependencies.
Lesser alliances should be working.
Every tree was dropping apples — reduced to only Buya and Kugnae; needs verification for
restriction in the cities.
Proximity broadcasts & fast-move
2026-08-20 · snugglezone
Proximity-limited broadcasts — combat, sound, and spell FX no longer go map-wide; sound uses
RTK's tight SAMEAREA box (±9/±8, edge-shifted), damage bars and animations use the wider AREA box
(±19/±17).
Fast-move persistence (the RE fix) — the client's runtime flag is driven verbatim from the 0x08
stats packet's body[46]; we were sending 0, silently forcing fast-move OFF on every stats refresh.
Now driven from the saved preference, so it sticks when toggled and survives relog.
Buya Library Caverns & the Ice Beast
2026-08-19 · snugglezone
New: Buya Library Caverns — all 5 tiers open via the Buya Library entrance.
New: Ice Beast quest — the full Frost sabre chain is in: Blood, the Nameless Hermit,
Myung-Suck, the lava crossing, and the beast itself.
Blood — no longer a shop; now offers Forget Secret (unlearn any spell/skill).
Combat — mobs now play a visible attack animation on every swing, not just the sound.
Movement — fast-move fixed; the walking stutter (worst mounted / in fresh areas) is gone.
Toggle in options (F10).
New @karma <value|tier> command: set karma exactly or snap to a named tier
(cat, dog, angel, …).
Known issue at the time: cavern mob stats/spawns were misconfigured — fix followed.
Multiplayer visibility & PvP
2026-08-18 · snugglezone
Multiplayer visibility & PvP
See players you walk up to — other players now stream in/out of view exactly like mobs
(viewport-gated + tracked), fixing "can't see users I walk up to until I gate in next to them."
PvP melee — swinging at a player on a PvP map now deals real damage, sharing one live-validated
damage formula between mob and player hits (target AC, positional rear-×2, Harden Body, deduction
all applied).
Arena ghosts — a player killed on a PvP map becomes a hidden, no-clip ghost: invisible to the
living (no corpse-camping), but visible to and blocking other ghosts.
Player collision — two players can no longer share a tile or no-clip through each other.
Cave ambush traps
Hidden ambush tiles in the mythic caves now burst mobs when stepped on and relocate a
replacement trap (under a per-map mob cap), porting RTK's mob_spawn/rabbitTrap/tigerTrap. Warrior
Watchful Eye reveals them; Rogue Spot Traps reveals the combat-trap family. Fall caves leave a
cosmetic "shiver" echo.
Chat & social
Group/clan chat — whisper to !! for party chat and ! for clan chat.
Proximity speech — NPC bubbles, mob talk, and cast shouts now carry only to nearby players
(say ±9/8, shout ±16) instead of map-wide.
Hand item / hand gold (h/H + gold gesture) now fold into the trade
window, with a quest-turn-in hook for NPCs.
Group "Join a group" status stays in sync on form/join/leave/disband.
Items & spells
Mage weapon penalty — mages read a weapon's small-damage range at ~¼ value (live-measured),
plus measured classFactor staircases for warrior/rogue/mage.
Confuse spell — a chance-based aggro reset that can set two adjacent mobs fighting each other;
Amnesia gains a take-hold chance.
Rearrange panes (Shift+C) — swap bag slots and spellbook slots in place.
Options, battle cries & Nagnang
2026-08-17 · snugglezone
Options menu
Magic Effect, Weather Change, and Wisdom now actually do something — and all four synced options
(those three plus Fast Move) now show the correct state, persist across closing/reopening the menu,
and survive relog.
Magic Effect off now suppresses spell graphics and sounds for you.
Wisdom streams periodic gameplay hints into your chat channel (~every 15 min) when on.
Opening the Options menu (F10) no longer dismounts your horse.
Combat
Power-strike battle cries (Berserk's K'YA~!, Whirlwind's Sa-AAA~~!, Assault, Desperate Attack,
Lethal Strike, etc.) now shout overhead instead of dumping into the chat box, and play the swing
animation rather than the spell-cast pose.
Whirlwind and Berserk can now strike other players in PvP areas.
Casting mechanics reworked; melee re-measured; item wards now actually apply.
World & content
Nagnang added to the world map with its warp corridors opened.
New quests: the Dog Linguist and Old Dog dog-spell questlines; trainer secrets are now locked to
their home city.
Spell rebalance: no-cast maps, buff slots, and post-cast costs.
Spawn system overhauled — per-creature respawn times and batched spawn groups.
Drinks now play a sip pose.
Social & misc
Group join/leave notices now go to the status pane instead of the chat box.