This repository is a fork of kwader2k/koolo. The contents of this README are intentionally limited to changes introduced in this fork; everything not mentioned here is inherited from the upstream project.
The upstream project builds with Go 1.23. This fork requires newer toolchain versions — it will not compile with the original Go and Garble versions.
| Tool | Upstream version | This fork | Install command |
|---|---|---|---|
| Go | 1.23 | 1.25.7 | Download from go.dev/dl |
| Garble | (any) | v0.15.0 | go install mvdan.cc/garble@v0.15.0 |
Before building this fork you must:
- Uninstall Go 1.24 / 1.23 (or whichever version you currently have).
- Install Go 1.25.7 — the
go.moddirective isgo 1.25.7; older compilers will refuse to build or produce subtle incompatibilities. - Install Garble v0.15.0 — earlier Garble releases are incompatible with Go 1.25 and will fail
during the obfuscation pass. Run:
go install mvdan.cc/garble@v0.15.0 - Verify with
go version(should printgo1.25.7) andgarble version(should printv0.15.0).
The included
better_build.batchecks both versions automatically and offers to install them if they are missing or outdated.
100 Go files changed — +2,720 / -738 lines across the categories below.
Upstream MovePointer teleports the cursor instantly — a single CursorPos call followed by
WM_NCHITTEST / WM_SETCURSOR / WM_MOUSEMOVE. This fork replaces that with a full
biomechanically-grounded trajectory system ported from
ck0i/SigmaDrift (sigmadrift.go — 372 lines, entirely new).
Key differences from upstream:
- Sigma-lognormal velocity primitives (Plamondon's Kinematic Theory): the cursor follows a lognormal CDF position profile with a configurable peak-time ratio, producing a natural acceleration/deceleration bell curve instead of an instantaneous jump.
- Two-phase surge architecture: a ballistic primary stroke covers 92–97 % of the distance, followed by 0–2 corrective sub-movements that simulate the undershoot/overshoot adjustment humans make when landing on a target.
- Ornstein-Uhlenbeck lateral drift: mean-reverting stochastic hand drift is applied perpendicular to the movement axis via Euler-Maruyama integration, modelling natural hand sway.
- Physiological tremor (8–12 Hz): sinusoidal tremor is overlaid on each axis, with amplitude suppressed at high cursor speed (proprioceptive gating).
- Signal-dependent noise (Harris-Wolpert): Gaussian noise proportional to instantaneous motor command magnitude is added each sample, reproducing the known relationship between movement speed and endpoint variability.
- Gamma-distributed inter-sample timing: sample intervals are drawn from a Gamma distribution (shape 3.5, mean ~7.8 ms) rather than a fixed polling rate, eliminating the constant-dt fingerprint that characterises bot-generated event streams.
- Lateral curvature profile: a
s^2(1-s)^3 arc scaled by movement angle (vertical > horizontal) adds the gentle lateral bow observed in human pointing movements. - Micro-correction pass (12 % probability): after the trajectory lands, there is a random chance of a 2–5 px overshoot followed by a brief dwell and re-aim to the exact target, breaking the otherwise perfectly-sharp endpoint distribution.
- Fitts' Law timing: movement duration is predicted from an index-of-difficulty formula
(
a + b * log2(D/W + 1)) with log-normal jitter, so short moves are fast and long moves take proportionally longer — matching the speed-accuracy trade-off of real hand movements. MovePointerrewrite: queriesLastCursorPos()for the current injected cursor position and plays back the full SigmaDrift path with per-sampleWM_MOUSEMOVEmessages and gamma-distributed sleeps. If no prior position is known (first call of the session), the animation is skipped entirely to avoid a spurious trajectory from (0, 0).memory_injector.goadditions: a newcursorPosKnownbool andLastCursorPos() (x, y int, ok bool)method soMovePointercan detect the first-call case.- Bounds clamping: intermediate trajectory points are clamped to the game window rect before
being packed into
lParamto prevent corrupt bit patterns from OU drift or tremor pushing coordinates negative or beyond the window boundary.
All fixed time.Sleep calls in the bot lifecycle, key sequences, character selection, and
supervisor flow are replaced with utils.Sleep() which applies the human-like timing distributions
below. This spans supervisor.go, single_supervisor.go, character_switch.go,
keyboard.go, and all character build files.
Sleep()rewrite: the core sleep function now draws from a Gamma(4, 0.25) distribution (mean multiplier = 1.0, right-skewed) instead of the upstream flat +/-30% uniform jitter. The multiplier is clamped to [0.4, 2.5] to prevent pathological extremes.- Session fatigue: a progressive multiplier from 1.0 to 1.25 rises linearly over the first
3 hours, modelling mild reaction-time slowdown in extended play sessions.
SetSessionStart()is called at each new game;sessionMuis aRWMutexwithRLockin the read path. RandGammaDurationMs: walk-polling and movement steps use a gamma-distributed duration instead of narrow uniform windows.RandLogNormal: idle gaps (inter-game pauses, cursor wander dwells) use a log-normal distribution matching empirical human idle-time data.- Click-position jitter: buff casts, CTA casts, and item pickup positions receive small random offsets. Pickup spiral coordinates get per-step random offsets.
- Attack / cast timing jitter: attack sleeps in all character builds (berserk barb, warcry barb, whirlwind barb, paladin, assassin, both barb leveling files, blizzard/fireball/hydraorb/lightning/ nova sorceress, hammerdin, foh, javazon, mosaic, trapsin, wind druid, druid leveling, necromancer leveling, amazon leveling, Smiter) have +/- jitter added to break metronomic cast intervals.
- Log-normal inter-game idle: after a game ends, a randomised pause is sampled from a log-normal
distribution (configurable via
InterGameIdleMinMs/InterGameIdleMaxMsin config, defaults 4000–20000 ms) instead of a flat 3-second or 5-second wait. The right-skewed distribution is harder to distinguish from human between-game gaps. - Idle cursor wander (
idleCursorWander): 0–4 small cursor movements to random on-screen positions during the inter-game pause, with a geometric-like count distribution (P(0)~55%, P(1)~25%, P(2)~11%, ...) to mimic human fidgeting on the character-select screen. - Randomised client-close waits: the fixed 3 s / 5 s waits after game-finish or error are
replaced with
RandRng(2500, 6000)andRandRng(4000, 9000)respectively. SetSessionStart()is called at the top of each new game so that the fatigue multiplier resets properly.- Removed the
defer runCancel();runCancel()is now called explicitly after the game loop exits to ensure per-game context cancellation fires at the right time.
4. Scheduler activation & dormant UI (internal/bot/scheduler.go, internal/server/, internal/config/)
- Per-supervisor scheduler activation tracking with mutex-guarded
ActivateCharacter/DeactivateCharacter/IsActivatedhelpers. - Scheduler is automatically activated on non-manual starts (including auto-start flow) and deactivated on Stop.
- Simple schedule mode added (default changed from
"timeSlots"to"simple"): just a daily start and stop time (supports overnight windows e.g. 22:00–06:00). New config fields:SimpleStartTime,SimpleStopTime. - New
WaitingForSchedulesupervisor status. - HTTP API exposes
Activated,ScheduleSummary,SimpleStopTime,WaitingForSchedule, andScheduledStartTimefields in scheduler status responses; ascheduleSummary()helper produces human-readable summaries for simple/duration/time-slots modes. cancelPendingStartis called on config save to prevent stale schedule goroutines from firing at now-outdated times.daysOfWeekarray reordered to start with Sunday and moved outside the loop.- Dashboard CSS adds dormant and header-badge states; dashboard JS shows a compact scheduler badge,
a dormant summary when the scheduler is enabled but not yet activated, improved countdown rendering
with
countdown-liveelements, and a 30-second auto-refresh to keep countdowns accurate.
- A
sync.RWMutexis added to protect thesupervisorsandcrashDetectorsmaps. - All map reads (
AvailableSupervisors,Stats,GetData,GetContext) holdRLock; mutations (Start,Stop,ReloadConfig) hold the write lock. Startperforms a double-check under write lock to prevent a race where two concurrent calls both pass the initialRLockexistence check.Stopextracts references under the lock but callss.Stop()andcd.Stop()outside the lock to avoid deadlocking withrestartFunc(the crash-detector goroutine).ReloadConfigtakes a snapshot of running supervisors and applies configs outside the lock.Runtimepreservation:ctx.CharacterCfg.Runtime(compiled NIP rules, tier rules, etc.) is saved before the reload and restored afterwards so pickit continues to work immediately after a hot-reload. The upstream code had this commented out.
6. Bot state, stash safety & logging refactors (internal/action/, internal/bot/, internal/context/, internal/game/)
- Per-supervisor monster-state tracking in
action/step/attack.go: monster state maps are keyed by bot name (mutex-guarded) to eliminateUnitIDcollisions between concurrent supervisors. - Stash gold slice guards in
action/stash.go: length checks before indexingStashedGold, safe total computation, removal of noisy debug prints. maxInteractionsinaction/step/pickup_item.gomade function-local so high-attempt modes get extra tries and the global variable is removed.bot/bot.goshouldReturnToTownsimplified with early returns; never returns if already in town or in UberTristram.bot/single_supervisor.goremoves the erroneous reset ofFailedToCreateGameAttemptsin the modal-absent branch.context/context.goGet()panics on unregistered goroutines to surface misuse;getGoroutineIDuses a smaller buffer and faster numeric parse.game/manager.goandgame/packet_sender.goreplacefmtdebug prints with structuredsloglogging.character/sorceress_leveling.godebug messages converted toctx.Logger.Debugwith context fields.bot/supervisor.gologGameStarthandles empty run list without panic.bot/supervisor.goremoves thedisconnected-based VK_DOWN/VK_UP workaround for character selection after reconnect — the code was fragile and is no longer needed.
InterGameIdleMinMs/InterGameIdleMaxMsfields added toCharacterCfg.Game— control the randomised idle pause between game exit and the next game creation (defaults 4000/20000 ms).SaveSupervisorConfignow callsValidate()beforeyaml.Marshalso that any field corrections (e.g. NovaSorceressBossStaticThreshold) are present in the written YAML. Upstream callsValidate()after marshalling, which means corrections are lost.
- Bounds checks added before accessing
NPC.Positions[0]acrossinteraction.go,anya.go,quests.go,cave.go,bone_ash.go,jail.go,izual.go,countess.go,A1.go. action/move.goshrine lookup: fix shadowed variable that caused the best-shrine result to always benil; result is now stored in a scoped variable and returned after the loop.action/repair.go: remove unused import alias; callcontext.Get()directly.action/vendor.go: replace removedbotCtxalias withcontext.Get(); adjust Jamella key sequence to skipVK_DOWN; enforceMaxGameLengthonly when it is greater than zero.
- After sending the Harrogath portal key sequence, wait, refresh game data, and re-query the portal; return an error if it is still missing (both act-4 portal locations).
- Act-5 waypoint usage guarded by existence check before calling
MoveToCoordsto prevent a nil dereference.
- Index layout corrected to row-major: buffer index function changed from
x*height + ytoy*width + x, matching the game grid's actual row-major memory layout. The upstream column-major index could silently corrupt the cost/cameFrom arrays for non-square grids. - Stale priority-queue entries skipped: a new guard
current.Cost > costSoFar[...]skips nodes that already have a cheaper path recorded, avoiding wasted expansions and subtly incorrect paths. - Struct literal field names:
data.Position{0, 1}changed todata.Position{X: 0, Y: 1}for clarity and forward-compatibility with struct changes. GetClosestWalkablePath: search step changed from 4 to 1 for finer resolution; loop bounds use<=instead of<to include the shell boundary; perimeter test simplified frommath.Absto direct==comparison, removing themathimport.- Lut Gholein grid fix: the non-walkable tile at (210, 13) is now written to a local grid copy
instead of mutating the shared
a.Grid, preventing permanent corruption of live map data. renderMapallocations reduced: the path-lookup map switches frommap[string]bool(fmt.Sprintf-keyed) tomap[data.Position]bool(struct-keyed), eliminating per-tile string allocation and thefmtimport. The redundantdraw.Drawcall is removed.
- Template error handling: every
ExecuteTemplatecall now checks the returned error and logs it withslog.Error. Upstream silently discards template rendering failures. - Method guards:
resetMuling,openDroplogs, andresetDroplogsendpoints now reject non-POST requests with 405 Method Not Allowed. - Telegram chat ID: parse error is only surfaced when Telegram is actually enabled, preventing a spurious validation failure when the field is empty and Telegram is off.
- Chest mutual exclusivity: when
InteractWithChestsis checked,InteractWithSuperChestsis forced off to prevent contradictory config. - Diablo
AttackFromDistanceform key: was reading the wrong field (gameLevelingHellRequiredFireRes); corrected togameDiabloAttackFromDistance. getIntFromFormdefault-value logging: the warning log now prints the actualdefaultValueinstead of a hardcoded0.BarbLeveling.UsePacketLearning: checkbox scope was incorrectly inside an innerifblock; moved to the correct scope so the value is always read from the form.- Pickit API: duplicate
sendJSONcall removed;strconv.Atoireplacesfmt.Sscanffor line-number parsing (clearer error handling). - Shopping wiring:
form.Has()replaces the custompostedBool()helper (which is removed), aligning with the standard library checkbox idiom. - Runewords:
strings.TrimSuffixreplaces manual string slicing for rune name cleanup.
event/listener.go:rand.Intn(math.MaxInt64)changed torand.Intn(math.MaxInt)to avoid overflow on 32-bit or future platforms.updater/revert.go:fmt.Errorf(result.Error)replaced witherrors.New(result.Error)to satisfy thego vet/staticcheckdiagnostic for non-constant format strings.updater/updater.go: added a log message when backing up old executables so the user can see progress during updates.keyboard.go:KeySequenceinter-key delay changed from fixedtime.Sleep(200ms)toutils.Sleep(200)for timing humanisation.
13. Telegram & Discord startup resilience (cmd/koolo/main.go, internal/remote/telegram/constructor.go)
- Non-fatal bot initialization: Discord and Telegram bot initialization errors are now logged as warnings instead of crashing the application. The bot continues running without the messaging service that failed. This fixes the reported issue where a TCP connection reset during Telegram API startup would crash the entire application.
- Telegram retry with exponential backoff: the Telegram constructor retries up to 3 times with exponential backoff (2 s → 4 s → 8 s) before giving up, handling transient network errors (TCP resets, DNS failures, timeouts) gracefully.
MessageSend.File→MessageSend.Files: migrated from the deprecated singularFilefield to theFilesslice (discordgo v0.29.0). BothsendItemScreenshotandsendScreenshotare updated. This prevents future breakage when the deprecated field is removed.
- Centralized
searchForAndariel()method: added 6 progressively deeper search positions in Andariel's chamber (Y coordinates 9560 → 9520), with the final position approaching Andariel's throne directly. Before killing, the run now callssearchForAndariel()which moves through each position and checks for the boss viadata.Monsters.FindOne(). This fixes the reported issue where the bot would stand at the chamber entrance unable to find Andariel because she was deeper in the room. The fix benefits all 20+ character classes since it lives in the run layer, not per-character. - Paladin leveling directed search:
KillAndariel()inpaladin_leveling.gonow usesaction.MoveToCoordstargeting Andariel's known throne coordinates(22548, 9520)instead ofRandomMovement()when the boss is not found. Random movement could move the character further away from the spawn; directed movement converges on her location.
- Town return detection with timeout: when the bot is unexpectedly teleported to town during
field movement (e.g., accidental TP click during combat), a
townReturnDetectedAttimer starts. After 5 seconds stuck in town, the bot proactively callsUsePortalInTown()to return to the field. Previously, theMoveToloop would spin indefinitely withSleep(100)calls, appearing as if the bot was standing still doing nothing. !pathFoundbranch missingcontinue: when the player was in town andUsePortalInTown()succeeded in the!pathFoundbranch, execution fell through into path step computation with a nilpath, causingpath[pathStep]to index out of range. The panic was silently swallowed by the goroutine recovery handler, killing the run goroutine while health/refresh goroutines kept ticking — producing the observed "idle" state. Acontinueis now added after the successful portal call so the loop restarts and recalculates the path from the post-portal position.- Defensive
pathStep >= 0guard: before indexing intopathin the non-town movement code path, a bounds guard now prevents out-of-range access ifpathis empty, mirroring the existing guard already present in the town branch.
- Increased reposition attempts:
repositionAttemptsthreshold raised from>= 1to>= 3, giving the bot more chances to angle around obstacles before giving up on a monster. This prevents premature target abandonment in tight corridors.
- Operator precedence bug fix: the
shouldStashIt()function had an erroneous|| i.Name == "HoradricStaff"that bypassed all stash-exclusion logic due to Go's operator precedence. The condition was removed so quest items are now correctly evaluated against the standard stash rules.
- All updater URLs point to this fork: the updater now clones, fetches, and checks against
Diobyte/Koolo-DiobyteVersioninstead ofkwader2k/koolo:repo.go: clone URL updatedgit.go:ensureUpstreamRemote()— upstream URL, expected URL, and contains-check all updatedpr.go:upstreamOwner→"Diobyte",upstreamRepo→"Koolo-DiobyteVersion"(affects GitHub API calls for PR listing, commit fetching, cherry-pick)
- GUI text updated: all
kwader2k/kooloreferences in the web UI (how-it-works panel, version fallback message, update status text, PR "Open" links) now showDiobyte/Koolo-DiobyteVersion. - Go module paths unchanged:
github.com/hectorgimenez/kooloreferences inGOGARBLEand-ldflagsare Go import paths matchinggo.modand are intentionally left as-is.
Three unbounded loops that could cause the bot to stand still indefinitely have been fixed:
swapWeapon()—swap_weapon.go: thefor {}loop had no max attempts. IfSwapToCTA()was called but no CTA existed (e.g.,UseSwapForBuffsenabled without a CTA equipped), the bot would spin forever pressing the weapon swap key every 500 ms. Fixed withmaxSwapAttempts = 6; after exhausting attempts, logs a warning and returns gracefully.WaitForGameToLoad()—context.go: thefor LoadingScreenloop had no timeout. If the loading screen flag got stuck (frozen game client, network issue), the bot would block forever. This is called from 6+ critical code paths. Fixed with a 30-second deadline; after timeout, logs a warning and proceeds.OpenPortal()—open_portal.go: thefor {}loop had no max attempts. If the portal object never appeared (laggy server, area restriction, game state desync), the loop would retry every 1 second indefinitely. Fixed withmaxPortalAttempts = 10; after exhausting attempts, returns an error that propagates up for proper game restart.
21. Blizzard Sorceress threat assessment & positioning rewrite (internal/character/blizzard_sorceress.go)
- Weighted threat scoring system: the old per-monster
needsRepositioning()binary check is replaced withassessThreat()which iterates all nearby enemies once and computes a weighted composite score factoring in:- Proximity (inverse distance from danger zone)
- Elite multiplier (×3.0 for elite mobs)
- Dangerous aura detection (Fanaticism, Might, Conviction, Holy Fire/Shock/Freeze — ×2.5)
- Aggregate monster count threshold (≥3 nearby enemies triggers regardless of individual scores)
- Threat-level thresholds: Low (3.0), Medium (8.0), High (15.0) drive graduated responses instead of the previous single-distance binary trigger.
- Dynamic reposition cooldown:
getRepositionCooldown()interpolates between 200 ms and 1200 ms based on HP percentage (60% weight) and normalized threat score (40% weight). Lower HP or higher threat → shorter cooldown → more frequent repositioning. Replaces the fixed 1-second cooldown. - Centroid-based escape vector:
findSafePosition()now computes the threat centroid (weighted center of all nearby enemies) and escapes away from the pack rather than away from a single arbitrary monster, preventing teleports into flanking groups. - Two-phase directional search: safe position candidates are first searched in a 180° cone centered on the escape vector (10° increments), falling back to a full 360° circle only if no walkable positions exist in the cone.
- Enhanced scoring formula: candidate positions are now scored with additional factors: centroid distance (+2.5 weight), nearby monster count penalty (−3.0 per mob), nearby elite/aura threat score penalty (−1.5), and a wall-blocked line-of-sight bonus (+1.0 per blocked dangerous monster) that favours positions where terrain shields from elites.
- Cooldown-phase repositioning: during Blizzard cooldown (when previously the bot would always primary-attack), the bot now repositions if the threat assessment warrants it, using the same safe-position logic. Falls back to primary attack only when the area is safe.
stutterStepStaticField()helper: Static Field usage on bosses (Izual, Diablo, Baal) is extracted into a shared helper that alternates between close-range Static Field casts and teleport-retreats to safe Blizzard distance. Includes HP-gated abort (< 50% HP), boss-alive checks, and Hell difficulty threshold detection (stops Static at ~35% boss HP since it cannot reduce below ~33%).- Nil-safety on boss lookups:
KillIzual,KillBaal, andKillDiablonow check thefoundreturn fromFindOnebefore using the monster. - Removed dead code: unused
needsRepositioning(), stale commented-outKillMephistoblock, and orphanedminMonsterDistance()helper are removed.
22. Clear area/room abort on area change & unreachable mob skip (internal/action/clear_area.go, internal/action/clear_level.go)
- Area-change detection: both
ClearThroughPath()andclearRoom()now record the starting area at entry and checkctx.Data.PlayerUnit.Areaeach iteration. If the player has been unexpectedly moved to a different area (death, chicken, waypoint), the function aborts immediately with a descriptive error instead of spinning against stale map data. - Skip unreachable monsters:
clearRoom()tracks askippedMonstersset. When the pathfinder cannot find a path to a target monster, the monster'sUnitIDis blacklisted for the remainder of that room clearing pass, preventing infinite retry loops on monsters stuck behind walls or in unreachable geometry.
23. Shared stash support for Horadric Cube & quest items (internal/action/horadric_cube.go, internal/action/cube_recipes.go, internal/run/cube.go, internal/run/quests.go)
When StashToShared is enabled, the Horadric Cube can legitimately end up in a shared stash tab.
Previously, multiple code paths only searched personal stash and inventory for the cube, causing
false "cube not found" errors and unnecessary re-fetch attempts.
ensureCubeIsOpen():Find("HoradricCube", ...)now includesitem.LocationSharedStash. The existingSwitchStashTab(cube.Location.Page + 1)formula already handles both personal (Page 0 → Tab 1) and shared pages (Page N → Tab N+1) correctly.CubeAddItems(): the oldrequiresPersonalStashif/else guard is replaced with a location-basedswitchthat navigates to whatever tab the item currently occupies, handling personal stash, shared stash, and DLC tabs uniformly.cube_recipes.go: material search locations extended to includeLocationSharedStash.run/cube.goandrun/quests.go: Horadric Cube presence checks now search shared stash, preventing the bot from re-running the cube quest when it already has one stashed.
Note: In D2R, the Horadric Cube can be placed in the shared stash. Other quest components (Staff of Kings, Amulet of the Viper, etc.) are server-side blocked from shared stash tabs, so the non-cube quest item path is defensive but does not affect gameplay.
24. Warlock leveling character (internal/character/warlock_leveling.go, internal/bot/character_create.go, internal/character/character.go, internal/server/templates/character_settings.gohtml)
Full leveling build for the Warlock class (D2R expansion class), selectable as
warlock_leveling in the dashboard and configuration.
- Two-phase skill progression: pre-respec (levels 1–44) focuses on the Fire tree (MiasmaBolt → RingOfFire → FlameWave → Apocalypse); at level 45 the character respecs into a Magic/Miasma build (MiasmaBolt → MiasmaChains → EnhancedEntropy → Abyss).
- Intelligent cooldown management: when the primary skill is on cooldown, the bot falls back to MiasmaBolt / MiasmaChains / primary attack in priority order, avoiding idle standing during cooldown windows.
- Danger-aware repositioning: at level 12+ the bot checks for enemies within
warlockDangerDistance(4 units) and teleports to a safe position betweenwarlockSafeDistanceandwarlockMaxDistance(6–15 units), with a 4-second cooldown between repositions. - Corpse consumption (Consume): post-respec the bot opportunistically casts Consume on nearby corpses (within 10 units + line of sight) for mana recovery, with a 1-second cooldown to avoid spam.
- Demon summoning:
PreCTABuffSkills()summons Goatman, Tainted, and Defiler pets when they are not already alive, keeping the army refreshed between fights. - Stat & skill point plans: complete
StatPoints()andSkillPoints()sequences covering levels 2–85+, including the respec pivot at level 45. - Boss kill sequences: dedicated
killBoss()with per-boss timeouts (220 s standard, 240 s for Baal) and persistentkillMonsterByName()for super-uniques. All 12 standard boss methods implemented (Countess, Andariel, Summoner, Duriel, Council, Mephisto, Izual, Diablo, Pindle, Ancients, Nihlathak, Baal). - Character creation:
classCoordsmap extended with Warlock screen coordinates. - Dashboard:
character_settings.gohtmladds the "Warlock (Leveling)" option to the class dropdown.
25. Stash tab/page memory & shared-stash fixes (internal/action/stash.go, internal/action/horadric_cube.go, internal/action/town.go, internal/context/context.go, internal/run/)
D2R remembers the last-viewed stash tab and shared page within a game session. The upstream code assumed every stash open lands on the personal tab, causing items to be placed on the wrong page. This group of changes tracks the stash UI state correctly.
HasOpenedStashflag (context.go): newCurrentGameHelper.HasOpenedStashbool. The first stash open each game setsCurrentStashTab = 1(personal); subsequent opens preserve the last known tab.OpenStash()/CloseStash():OpenStashsets the initial tab only on first use;CloseStashno longer resetsCurrentStashTabto 0, since the game remembers the tab.- Shared page force-reset:
switchStashTabHDandswitchStashTabLegacyno longer assume the Shared button lands on page 1. They now click the "prev page" arrowsharedPages − 1times to force-reset to page 1, then navigate forward to the target page. Supports both 3-page (non-DLC) and 5-page (DLC) shared stashes. StashFull()optimization: instead of opening the stash UI and clicking through every shared tab (which corruptedCurrentStashTab), shared stash items are now read directly from memory viactx.Data.Inventory.ByLocation(item.LocationSharedStash). This is faster, non-destructive, and works even when the stash is closed.OpenStashconsolidation:cows.go,duriel.go,leveling_act2.go, andhoradric_cube.goall replaced inlineFindOne(object.Bank)+InteractObjectwith the centralizedaction.OpenStash()helper, gaining the tab-tracking behaviour automatically.duriel.goandleveling_act2.goadditionally callSwitchStashTab(1)after opening, since the Horadric Staff is always in personal stash.uber_helper.go:openStash()now setsHasOpenedStashconsistently.- Mule page filtering:
mule.gonow readsSharedStashPagesdynamically (supporting DLC) and filtersByLocation(item.LocationSharedStash)items by theirLocation.Pageto only move items from the currently displayed page, preventing cross-page item confusion.
26. Tal Rasha Tombs TP exhaustion fix (internal/run/tal_rasha_tombs.go) — #9
- Root cause:
TalRashaTombs.Run()loops through all 7 tombs. After clearing each tomb it called bareaction.ReturnTown(), which opens a TP and goes to town but never refills consumables. After several clears the TP tome ran out of charges and the nextReturnTown()failed with"no tp item, can't open portal". - Fix: replaced
action.ReturnTown()withaction.InRunReturnTownRoutine(), which performs the full between-run town routine (corpse recovery, belt management, identify, vendor refill including TP scrolls and potions, stash, gamble, cube recipes, etc.) between tomb clears — matching the pattern used by other multi-segment runs in the codebase.
27. Vendor visit for TP restock bypasses gold gate (internal/action/vendor.go) — #10
- Root cause:
shouldVisitVendor()checkedTotalPlayerGold() < 1000before evaluating whether the character needed TP scrolls. When the player had low gold (< 1000) but a depleted TP tome (< 5 charges), the function returnedfalseearly — the character never visited the vendor and was stranded in the field without a way back to town. - Fix: the
ShouldBuyTPs()check is moved above the 1000-gold gate so TP restocking is always prioritised. In D2R the game pulls gold from shared stash tabs during vendor purchases, butTotalPlayerGold()only sums personal gold + personal stash (excluding shared stash), so any gold-based guard on the TP path would incorrectly skip the vendor when shared stash gold is available. - Downstream safety:
BuyConsumables()already has its own gold guards — TP Tome purchase requires > 450 g, and every individual buy call goes throughbuyItemOrAbortOnNoGold()which detects unchanged gold and aborts gracefully. Worst case with truly zero gold everywhere is a single no-op vendor visit per town cycle, which is far better than being stranded. ShouldBuyTPs()was also removed from the combinedShouldBuyPotions() || ShouldBuyIDs()condition that follows the gold gate, since it is now unreachable there (dead code removal).
For a file-level diff against upstream run:
git fetch upstream && git diff --stat upstream/main
- The game must still be set to English. 1280x720 windowed mode and LOD 1.13c are required as usual.
- All other documentation (installation, usage, pickit rules, etc.) is unchanged from upstream — refer to the Diobyte/Koolo-DiobyteVersion README or the original kwader2k/koolo README for full details.
This README covers only the modifications made in this fork. See the upstream project for the full Koolo documentation.