commit a5ccd5e6911100af7dc6adaa5280a32d238353ad Author: Anna Clemens Date: Sun Mar 27 22:40:52 2022 -0400 chore: initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1db30bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,365 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# Packaging +pack/ + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd diff --git a/LiveSplit.TZA.sln b/LiveSplit.TZA.sln new file mode 100755 index 0000000..1d74351 --- /dev/null +++ b/LiveSplit.TZA.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSplit.TZA", "LiveSplit.TZA\LiveSplit.TZA.csproj", "{2AD5AFB0-A544-462C-9A42-4254898C44A6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2AD5AFB0-A544-462C-9A42-4254898C44A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2AD5AFB0-A544-462C-9A42-4254898C44A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2AD5AFB0-A544-462C-9A42-4254898C44A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2AD5AFB0-A544-462C-9A42-4254898C44A6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/LiveSplit.TZA/GameMemory.cs b/LiveSplit.TZA/GameMemory.cs new file mode 100755 index 0000000..18f6768 --- /dev/null +++ b/LiveSplit.TZA/GameMemory.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using LiveSplit.ComponentUtil; +using LiveSplit.TZA.Splits; + +namespace LiveSplit.TZA; + +public class GameMemory : MemoryWatcherList { + private Process Game { get; } + internal MemoryWatcher IsLoaded { get; } + internal MemoryWatcher ConfigMenu { get; } + internal MemoryWatcher Stage { get; } + internal MemoryWatcher Location { get; } + internal MemoryWatcher BossHp { get; } + internal MemoryWatcher UndyingInfo { get; } + internal StringWatcher Cutscene { get; } + internal Dictionary> Levels { get; } = new(); + + public GameMemory(Process game) { + this.Game = game; + var scanner = new SignatureScanner(game, game.MainModuleWow64Safe().BaseAddress, game.MainModuleWow64Safe().ModuleMemorySize); + + var configPtrIns = scanner.Scan(new SigScanTarget(3, "48 89 1D ?? ?? ?? ?? 89 BB ?? ?? ?? ?? 66 89 BB")); + this.ConfigMenu = new MemoryWatcher(new DeepPointer(configPtrIns + 4 + game.ReadValue(configPtrIns))) { + FailAction = MemoryWatcher.ReadFailAction.SetZeroOrNull, + }; + + var dataPtrIns = scanner.Scan(new SigScanTarget(3, "48 8D 0D ?? ?? ?? ?? 41 B8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 33 C9 E8")); + if (!new DeepPointer(dataPtrIns + 4 + game.ReadValue(dataPtrIns)).DerefOffsets(this.Game, out var dataPtr)) { + dataPtr = IntPtr.Zero; + } + + this.Stage = new MemoryWatcher(dataPtr + 0x200) { + FailAction = MemoryWatcher.ReadFailAction.SetZeroOrNull, + }; + + this.Location = new MemoryWatcher(dataPtr - 0x90) { + FailAction = MemoryWatcher.ReadFailAction.SetZeroOrNull, + }; + + var bossHpIns = scanner.Scan(new SigScanTarget(3, "48 8D 1D ?? ?? ?? ?? 8B F5 48 89 29 8B FD 4C 8B")); + if (!new DeepPointer(bossHpIns + 4 + game.ReadValue(bossHpIns)).DerefOffsets(this.Game, out var bossHpPtr)) { + bossHpPtr = IntPtr.Zero; + } + + this.BossHp = new MemoryWatcher(bossHpPtr - 4) { + FailAction = MemoryWatcher.ReadFailAction.SetZeroOrNull, + }; + + var partyIns = scanner.Scan(new SigScanTarget(15, "F2 0F 10 05 ?? ?? ?? ?? F2 0F 11 41 ?? 8B 05 ?? ?? ?? ?? 89 41 28 C3")); + if (!new DeepPointer(partyIns + 4 + game.ReadValue(partyIns)).DerefOffsets(this.Game, out var partyPtr)) { + partyPtr = IntPtr.Zero; + } + + partyPtr += 4 + 8; + + foreach (var character in (Character[]) Enum.GetValues(typeof(Character))) { + this.Levels[character] = new MemoryWatcher(partyPtr + 0x1C8 * (int) character + 0x1C2) { + FailAction = MemoryWatcher.ReadFailAction.SetZeroOrNull, + }; + } + + var cutsceneNameIns = scanner.Scan(new SigScanTarget(3, "48 8B 05 ?? ?? ?? ?? 48 3B 05 ?? ?? ?? ?? 74 11 C6 00 00 48 8B 05 ?? ?? ?? ?? 48 89 05 ?? ?? ?? ?? 48 63 0D")); + if (!new DeepPointer(cutsceneNameIns + 4 + game.ReadValue(cutsceneNameIns)).DerefOffsets(this.Game, out var cutsceneNamePtr)) { + cutsceneNamePtr = IntPtr.Zero; + } + + this.Cutscene = new StringWatcher(new DeepPointer(cutsceneNamePtr, 0), ReadStringType.UTF8, 32) { + FailAction = MemoryWatcher.ReadFailAction.SetZeroOrNull, + }; + + var isLoadedIns = scanner.Scan(new SigScanTarget(3, "4C 8D 3D ?? ?? ?? ?? 48 89 0D ?? ?? ?? ?? 48 85 C0 74 31 8B 48 0C E8")); + if (!new DeepPointer(isLoadedIns + 4 + game.ReadValue(isLoadedIns)).DerefOffsets(this.Game, out var isLoadedPtr)) { + isLoadedPtr = IntPtr.Zero; + } + + this.IsLoaded = new MemoryWatcher(isLoadedPtr) { + FailAction = MemoryWatcher.ReadFailAction.SetZeroOrNull, + }; + + var undyingInfoIns = scanner.Scan(new SigScanTarget(17, "33 C9 B8 ?? ?? ?? ?? 66 89 05 ?? ?? ?? ?? 48 89 0D ?? ?? ?? ?? 89 0D ?? ?? ?? ?? 66 89 0D")); + this.UndyingInfo = new MemoryWatcher(new DeepPointer(undyingInfoIns + 4 + game.ReadValue(undyingInfoIns), 0)) { + FailAction = MemoryWatcher.ReadFailAction.SetZeroOrNull, + }; + } + + public bool Update() { + if (this.Game.HasExited) { + return false; + } + + this.IsLoaded.Update(this.Game); + this.ConfigMenu.Update(this.Game); + this.Stage.Update(this.Game); + this.Location.Update(this.Game); + this.BossHp.Update(this.Game); + this.UndyingInfo.Update(this.Game); + + foreach (var level in this.Levels.Values) { + level.Update(this.Game); + } + + this.Cutscene.Update(this.Game); + + return true; + } +} + +[StructLayout(LayoutKind.Explicit, Pack = 1)] +internal struct UndyingInfo { + [FieldOffset(0x48)] + internal readonly uint Hp; +} diff --git a/LiveSplit.TZA/LiveSplit.TZA.csproj b/LiveSplit.TZA/LiveSplit.TZA.csproj new file mode 100755 index 0000000..7fbf453 --- /dev/null +++ b/LiveSplit.TZA/LiveSplit.TZA.csproj @@ -0,0 +1,33 @@ + + + + net461 + enable + enable + preview + true + + + + + + + + + ..\..\LiveSplit\LiveSplit\bin\Release\LiveSplit.Core.dll + false + + + + ..\..\LiveSplit\LiveSplit\bin\Release\UpdateManager.dll + false + + + + + + UserControl + + + + diff --git a/LiveSplit.TZA/Resources/AssemblyInfo.cs b/LiveSplit.TZA/Resources/AssemblyInfo.cs new file mode 100755 index 0000000..9f85eaf --- /dev/null +++ b/LiveSplit.TZA/Resources/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using LiveSplit.TZA; +using LiveSplit.UI.Components; + +[assembly: ComponentFactory(typeof(TzaFactory))] diff --git a/LiveSplit.TZA/SplitLogic.cs b/LiveSplit.TZA/SplitLogic.cs new file mode 100755 index 0000000..b586abc --- /dev/null +++ b/LiveSplit.TZA/SplitLogic.cs @@ -0,0 +1,259 @@ +using LiveSplit.Model; +using LiveSplit.TZA.Splits; + +namespace LiveSplit.TZA; + +internal class SplitLogic { + internal static readonly List Splits = new() { + new SplitInfo( + SplitId.StartGame, + new GameStart() + ), + new SplitInfo( + SplitId.AirCutterRemora, + new KillBoss(16, 275, 1_000) + ), + new SplitInfo( + SplitId.Reks, + new Cutscene("naf_b0401", CutsceneAction.Enter), + new StageChange(25) + ), + new SplitInfo( + SplitId.Rats, + new Cutscene("grm_g0100", CutsceneAction.Enter), + new Cutscene("grm_g0100", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.RogueTomato, + new StageChange(78) + ), + new SplitInfo( + SplitId.Sunstone, + new EnterLocationWithStage(306, 160) + ), + new SplitInfo( + SplitId.Dustia, + new LevelUp(Character.Vaan, 13) + ), + new SplitInfo( + SplitId.Flans, + new StageChange(265) + ), + new SplitInfo( + SplitId.Firemane, + new KillBoss(265, 329), + new Cutscene("grm_d0102", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.MimicQueen, + new KillBoss(340, 69), + new Cutscene("mic_c0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.LhusuMines, + new EnterLocationWithStage(806, 680) + ), + new SplitInfo( + SplitId.Judges, + new StageChange(966) + ), + new SplitInfo( + SplitId.JudgeGhis, + new KillBoss(1000, 552, 1_030), + new Cutscene("rsn_a0102", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Garuda, + new KillBoss(1352, 403), + new Cutscene("rwg_a0181", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.DemonWall2, + new KillBoss(1364, 407), + new Cutscene("rwg_b0202", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Belias, + new KillBoss(1376, 415), + new Cutscene("rwg_d0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Vossler, + new KillBoss(1460, 872), + new Cutscene("rsn_z0201", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.EnterEruytVillage, + new EnterLocation(768) + ), + new SplitInfo( + SplitId.LeaveEruytVillage, + new EnterLocationWithStage(751, 1790) + ), + new SplitInfo( + SplitId.OsmonePlain, + new EnterLocation(712) + ), + new SplitInfo( + SplitId.Jellies, + new EnterLocation(718) + ), + new SplitInfo( + SplitId.Tiamat, + new KillBoss(1820, 722), + new Cutscene("hne_b0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.EnterStilshrine, + new EnterLocation(597) + ), + new SplitInfo( + SplitId.Vinuskar, + new KillBoss(2057, 603), + new Cutscene("mrm_c0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Mateus, + new KillBoss(2100, 612), + new Cutscene("mrm_e0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.JudgeBergan, + new KillBoss(2290, 737), + new Cutscene("bul_a0201", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Salikawood, + new EnterLocation(483) + ), + new SplitInfo( + SplitId.PhonCoastTchitaUplands, + new EnterLocation(187) + ), + new SplitInfo( + SplitId.Mandragoras, + new Cutscene("rui_d0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Ahriman, + new KillBoss(new uint[] { 3150, 3200 }, 198), + new Cutscene("rui_c0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Archades, + new EnterLocation(1008) + ), + new SplitInfo( + SplitId.Cid1, + new KillBoss(3440, 286, 18_247), + new Cutscene("dor_b0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Rafflesia, + new KillBoss(4160, 545), + new Cutscene("mfr_b0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Daedalus, + new KillBoss(4295, 687), + new Cutscene("gil_a0201", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Tyrant, + new KillBoss(4320, 695), + new Cutscene("gil_c0201", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Shemhazai, + new KillBoss(4370, 698), + new Cutscene("gil_d0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Hydro, + new KillBoss(5200, 522), + new Cutscene("rwf_c0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Pandaemonium, + new KillBoss(5303, 1113), + new Cutscene("rbl_c0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Slyt, + new KillBoss(5320, 1121), + new Cutscene("rbl_f0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Fenrir, + new KillBoss(5320, 1132), + new Cutscene("rbl_j0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Hashmal, + new KillBoss(5320, 670), + new Cutscene("dgl_h0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Cid2, + new Cutscene("dgl_g0103", CutsceneAction.Exit) + // new KillBoss(0, 0, 20_523) - too many boss HP bars in same stage and area + ), + new SplitInfo( + SplitId.Gabranth, + new KillBoss(6600, 779), + new Cutscene("bhm_b0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.Vayne, + new KillBoss(6750, 782), + new Cutscene("bhm_c0101", CutsceneAction.Exit) + ), + new SplitInfo( + SplitId.VayneNovus, + new KillBoss(new uint[] { 6850, 6900 }, 782) + ), + new SplitInfo( + SplitId.TheUndying, + new TheUndying() + ), + }; + + private HashSet Completed { get; } = new(); + + internal LogicResult Tick(TzaControl control, TimerPhase phase, GameMemory memory) { + // never do anything if the timer is ended + if (phase == TimerPhase.Ended) { + return LogicResult.None; + } + + foreach (var info in Splits) { + if (this.Completed.Contains(info.Id)) { + continue; + } + + if (!control.EnabledSplits.TryGetValue(info.Id, out var idx)) { + continue; + } + + if (idx >= info.Splits.Length) { + continue; + } + + var result = info.Splits[idx].Calculate(phase, memory); + if (result == LogicResult.None) { + continue; + } + + this.Completed.Add(info.Id); + return result; + } + + return LogicResult.None; + } +} + +internal enum LogicResult { + None, + Start, + Split, +} diff --git a/LiveSplit.TZA/Splits/Cutscene.cs b/LiveSplit.TZA/Splits/Cutscene.cs new file mode 100755 index 0000000..45e3d6c --- /dev/null +++ b/LiveSplit.TZA/Splits/Cutscene.cs @@ -0,0 +1,42 @@ +using LiveSplit.Model; + +namespace LiveSplit.TZA.Splits; + +internal class Cutscene : ISplit { + private string Name { get; } + private CutsceneAction Action { get; } + + public Cutscene(string name, CutsceneAction action) { + this.Name = name; + this.Action = action; + } + + public LogicResult Calculate(TimerPhase phase, GameMemory memory) { + if (phase != TimerPhase.Running) { + return LogicResult.None; + } + + if (!memory.Cutscene.Changed) { + return LogicResult.None; + } + + switch (this.Action) { + case CutsceneAction.Enter when memory.Cutscene.Current != this.Name: + case CutsceneAction.Exit when memory.Cutscene.Old != this.Name: + return LogicResult.None; + default: + return LogicResult.Split; + } + } + + public string GetHumanName() => this.Action switch { + CutsceneAction.Enter => "After cutscene starts", + CutsceneAction.Exit => "After cutscene ends", + _ => "Unknown cutscene action - please report this", + }; +} + +internal enum CutsceneAction { + Enter, + Exit, +} diff --git a/LiveSplit.TZA/Splits/EnterLocation.cs b/LiveSplit.TZA/Splits/EnterLocation.cs new file mode 100755 index 0000000..2d91a41 --- /dev/null +++ b/LiveSplit.TZA/Splits/EnterLocation.cs @@ -0,0 +1,26 @@ +using LiveSplit.Model; +using LiveSplit.TZA.Util; + +namespace LiveSplit.TZA.Splits; + +internal class EnterLocation : ISplit { + private ushort Location { get; } + + internal EnterLocation(ushort location) { + this.Location = location; + } + + public LogicResult Calculate(TimerPhase phase, GameMemory memory) { + if (phase != TimerPhase.Running) { + return LogicResult.None; + } + + if (!memory.Location.Changed || memory.Location.Current != this.Location) { + return LogicResult.None; + } + + return LogicResult.Split; + } + + public string GetHumanName() => $"After entering {LocationNames.Get(this.Location)}"; +} diff --git a/LiveSplit.TZA/Splits/EnterLocationWithStage.cs b/LiveSplit.TZA/Splits/EnterLocationWithStage.cs new file mode 100755 index 0000000..4e0fd5c --- /dev/null +++ b/LiveSplit.TZA/Splits/EnterLocationWithStage.cs @@ -0,0 +1,32 @@ +using LiveSplit.Model; +using LiveSplit.TZA.Util; + +namespace LiveSplit.TZA.Splits; + +internal class EnterLocationWithStage : ISplit { + private ushort Location { get; } + private uint Stage { get; } + + internal EnterLocationWithStage(ushort location, uint stage) { + this.Location = location; + this.Stage = stage; + } + + public LogicResult Calculate(TimerPhase phase, GameMemory memory) { + if (phase != TimerPhase.Running) { + return LogicResult.None; + } + + if (memory.Stage.Current != this.Stage) { + return LogicResult.None; + } + + if (!memory.Location.Changed || memory.Location.Current != this.Location) { + return LogicResult.None; + } + + return LogicResult.Split; + } + + public string GetHumanName() => $"After entering {LocationNames.Get(this.Location)} during game stage {this.Stage}"; +} diff --git a/LiveSplit.TZA/Splits/GameStart.cs b/LiveSplit.TZA/Splits/GameStart.cs new file mode 100755 index 0000000..04cbdbb --- /dev/null +++ b/LiveSplit.TZA/Splits/GameStart.cs @@ -0,0 +1,30 @@ +using LiveSplit.Model; + +namespace LiveSplit.TZA.Splits; + +internal class GameStart : ISplit { + public LogicResult Calculate(TimerPhase phase, GameMemory memory) { + if (phase != TimerPhase.NotRunning) { + return LogicResult.None; + } + + if (memory.Stage.Current != 0) { + return LogicResult.None; + } + + if (memory.Location.Current != 12) { + return LogicResult.None; + } + + if (memory.ConfigMenu.Current != 0 || !memory.ConfigMenu.Changed) { + return LogicResult.None; + } + + // this is extremely finicky + // going from the new game screen to ANY screen activates this + + return LogicResult.Start; + } + + public string GetHumanName() => "After selecting New Game"; +} diff --git a/LiveSplit.TZA/Splits/ISplit.cs b/LiveSplit.TZA/Splits/ISplit.cs new file mode 100755 index 0000000..bc23cef --- /dev/null +++ b/LiveSplit.TZA/Splits/ISplit.cs @@ -0,0 +1,9 @@ +using LiveSplit.Model; + +namespace LiveSplit.TZA.Splits; + +internal interface ISplit { + LogicResult Calculate(TimerPhase phase, GameMemory memory); + + string GetHumanName(); +} diff --git a/LiveSplit.TZA/Splits/KillBoss.cs b/LiveSplit.TZA/Splits/KillBoss.cs new file mode 100755 index 0000000..5423ec0 --- /dev/null +++ b/LiveSplit.TZA/Splits/KillBoss.cs @@ -0,0 +1,44 @@ +using LiveSplit.Model; +using LiveSplit.TZA.Util; + +namespace LiveSplit.TZA.Splits; + +internal class KillBoss : ISplit { + private uint[] Stage { get; } + private ushort Location { get; } + private uint HpRemaining { get; } + + internal KillBoss(uint stage, ushort location, uint hpRemaining = 0) { + this.Stage = new[] { stage }; + this.Location = location; + this.HpRemaining = hpRemaining; + } + + internal KillBoss(uint[] stage, ushort location, uint hpRemaining = 0) { + this.Stage = stage; + this.Location = location; + this.HpRemaining = hpRemaining; + } + + public LogicResult Calculate(TimerPhase phase, GameMemory memory) { + if (phase != TimerPhase.Running) { + return LogicResult.None; + } + + if (!this.Stage.Contains(memory.Stage.Current)) { + return LogicResult.None; + } + + if (memory.Location.Current != this.Location) { + return LogicResult.None; + } + + if (!memory.BossHp.Changed || memory.BossHp.Current > this.HpRemaining) { + return LogicResult.None; + } + + return LogicResult.Split; + } + + public string GetHumanName() => $"When boss in {LocationNames.Get(this.Location)} has {this.HpRemaining:N0} HP remaining"; +} diff --git a/LiveSplit.TZA/Splits/LevelUp.cs b/LiveSplit.TZA/Splits/LevelUp.cs new file mode 100755 index 0000000..bb11c1d --- /dev/null +++ b/LiveSplit.TZA/Splits/LevelUp.cs @@ -0,0 +1,37 @@ +using LiveSplit.Model; + +namespace LiveSplit.TZA.Splits; + +internal class LevelUp : ISplit { + private Character Character { get; } + private byte Level { get; } + + public LevelUp(Character character, byte level) { + this.Character = character; + this.Level = level; + } + + public LogicResult Calculate(TimerPhase phase, GameMemory memory) { + if (phase != TimerPhase.Running) { + return LogicResult.None; + } + + var watcher = memory.Levels[this.Character]; + if (!watcher.Changed || watcher.Old != this.Level - 1 || watcher.Current != this.Level) { + return LogicResult.None; + } + + return LogicResult.Split; + } + + public string GetHumanName() => $"When {this.Character} reaches level {this.Level}"; +} + +internal enum Character { + Vaan = 0, + Ashe = 1, + Fran = 2, + Balthier = 3, + Basch = 4, + Penelo = 5, +} diff --git a/LiveSplit.TZA/Splits/SplitInfo.cs b/LiveSplit.TZA/Splits/SplitInfo.cs new file mode 100755 index 0000000..4824791 --- /dev/null +++ b/LiveSplit.TZA/Splits/SplitInfo.cs @@ -0,0 +1,117 @@ +namespace LiveSplit.TZA.Splits; + +internal class SplitInfo { + internal uint Id { get; } + internal string Name { get; } + internal ISplit[] Splits { get; } + + internal SplitInfo(SplitId id, params ISplit[] splits) { + this.Id = (uint) id; + this.Name = id.Name(); + this.Splits = splits; + } +} + +// NOTE: reordering is a breaking change +internal enum SplitId { + StartGame = 1, + Reks, + Rats, + RogueTomato, + Dustia, + Firemane, + MimicQueen, + JudgeGhis, + Garuda, + Belias, + Jellies, + Tiamat, + EnterStilshrine, + Vinuskar, + Mateus, + JudgeBergan, + Mandragoras, + Ahriman, + Cid1, + Rafflesia, + Daedalus, + Tyrant, + Shemhazai, + Hydro, + Pandaemonium, + Slyt, + Fenrir, + Hashmal, + Cid2, + Gabranth, + TheUndying, + + // added later + Judges, + DemonWall2, + Vossler, + Flans, + Sunstone, + AirCutterRemora, + Vayne, + VayneNovus, + LhusuMines, + LeaveEruytVillage, + OsmonePlain, + Salikawood, + PhonCoastTchitaUplands, + Archades, + EnterEruytVillage, +} + +internal static class SplitIdExt { + internal static string Name(this SplitId id) => id switch { + SplitId.StartGame => "Start Game", + SplitId.Reks => "Reks", + SplitId.Rats => "Rats", + SplitId.RogueTomato => "Rogue Tomato", + SplitId.Dustia => "Dustia", + SplitId.Firemane => "Firemane", + SplitId.MimicQueen => "Mimic Queen", + SplitId.JudgeGhis => "Judge Ghis", + SplitId.Garuda => "Garuda", + SplitId.Belias => "Belias", + SplitId.Jellies => "Jellies", + SplitId.Tiamat => "Tiamat", + SplitId.EnterStilshrine => "Enter Stilshrine", + SplitId.Vinuskar => "Vinuskar", + SplitId.Mateus => "Mateus", + SplitId.JudgeBergan => "Judge Bergan", + SplitId.Mandragoras => "Mandragoras", + SplitId.Ahriman => "Ahriman", + SplitId.Cid1 => "Cid 1", + SplitId.Rafflesia => "Rafflesia", + SplitId.Daedalus => "Daedalus", + SplitId.Tyrant => "Tyrant", + SplitId.Shemhazai => "Shemhazai", + SplitId.Hydro => "Hydro", + SplitId.Pandaemonium => "Pandaemonium", + SplitId.Slyt => "Slyt", + SplitId.Fenrir => "Fenrir", + SplitId.Hashmal => "Hashmal", + SplitId.Cid2 => "Cid 2", + SplitId.Gabranth => "Gabranth", + SplitId.TheUndying => "The Undying", + SplitId.Judges => "Judges", + SplitId.DemonWall2 => "Demon Wall 2", + SplitId.Vossler => "Vossler", + SplitId.Flans => "Flans", + SplitId.AirCutterRemora => "Air Cutter Remora", + SplitId.Sunstone => "Sunstone", + SplitId.Vayne => "Vayne", + SplitId.VayneNovus => "Vayne Novus", + SplitId.LhusuMines => "Lhusu Mines", + SplitId.LeaveEruytVillage => "Leave Eruyt Village", + SplitId.OsmonePlain => "Osmone Plain", + SplitId.Salikawood => "Salikawood", + SplitId.PhonCoastTchitaUplands => "Phon Coast & Tchita Uplands", + SplitId.Archades => "Archades", + SplitId.EnterEruytVillage => "Enter Eruyt Village", + _ => throw new ArgumentOutOfRangeException(nameof(id), id, null), + }; +} diff --git a/LiveSplit.TZA/Splits/StageChange.cs b/LiveSplit.TZA/Splits/StageChange.cs new file mode 100755 index 0000000..e75eaaf --- /dev/null +++ b/LiveSplit.TZA/Splits/StageChange.cs @@ -0,0 +1,26 @@ +using LiveSplit.Model; + +namespace LiveSplit.TZA.Splits; + +internal class StageChange : ISplit { + private uint Stage { get; } + + internal StageChange(uint stage) { + this.Stage = stage; + } + + public LogicResult Calculate(TimerPhase phase, GameMemory memory) { + if (phase != TimerPhase.Running) { + return LogicResult.None; + } + + // previous stage being 0 means either new game, trial, or loading save + if (!memory.Stage.Changed || memory.Stage.Old == 0 || memory.Stage.Current != this.Stage) { + return LogicResult.None; + } + + return LogicResult.Split; + } + + public string GetHumanName() => $"When the game stage advances to {this.Stage}"; +} diff --git a/LiveSplit.TZA/Splits/TheUndying.cs b/LiveSplit.TZA/Splits/TheUndying.cs new file mode 100755 index 0000000..c7e6288 --- /dev/null +++ b/LiveSplit.TZA/Splits/TheUndying.cs @@ -0,0 +1,27 @@ +using LiveSplit.Model; + +namespace LiveSplit.TZA.Splits; + +internal class TheUndying : ISplit { + public LogicResult Calculate(TimerPhase phase, GameMemory memory) { + if (phase != TimerPhase.Running) { + return LogicResult.None; + } + + if (memory.Stage.Current != 7100) { + return LogicResult.None; + } + + if (memory.Location.Current != 785) { + return LogicResult.None; + } + + if (!memory.UndyingInfo.Changed || memory.UndyingInfo.Current.Hp != 0) { + return LogicResult.None; + } + + return LogicResult.Split; + } + + public string GetHumanName() => "On last hit on The Undying"; +} diff --git a/LiveSplit.TZA/TzaComponent.cs b/LiveSplit.TZA/TzaComponent.cs new file mode 100755 index 0000000..8b6eaf3 --- /dev/null +++ b/LiveSplit.TZA/TzaComponent.cs @@ -0,0 +1,106 @@ +using System.Diagnostics; +using System.Timers; +using System.Windows.Forms; +using System.Xml; +using LiveSplit.Model; +using LiveSplit.UI; +using LiveSplit.UI.Components; +using SystemTimer = System.Timers.Timer; + +namespace LiveSplit.TZA; + +public class TzaComponent : LogicComponent { + public override string ComponentName => "FFXII: The Zodiac Age"; + + internal TimerModel Timer { get; } + internal GameMemory? GameMemory { get; set; } + private TzaControl Control { get; } + private SystemTimer UpdateTimer { get; } + private SplitLogic Logic { get; set; } + + public TzaComponent(LiveSplitState state) { + this.Timer = new TimerModel { CurrentState = state }; + this.Control = new TzaControl(); + this.Logic = new SplitLogic(); + this.UpdateTimer = new SystemTimer(16f); + this.UpdateTimer.Elapsed += this.UpdateTimeUpdate; + this.UpdateTimer.Start(); + + this.Timer.CurrentState.OnStart += this.OnStart; + this.Timer.CurrentState.OnReset += this.OnReset; + } + + public override void Dispose() { + this.Timer.CurrentState.OnReset -= this.OnReset; + this.Timer.CurrentState.OnStart += this.OnStart; + this.UpdateTimer.Stop(); + this.UpdateTimer.Dispose(); + } + + private void OnStart(object sender, EventArgs e) { + this.Timer.InitializeGameTime(); + } + + private void OnReset(object sender, TimerPhase value) { + this.Logic = new SplitLogic(); + } + + private void UpdateTimeUpdate(object? sender, ElapsedEventArgs? e) { + // if (this.Timer.CurrentState.Run.GameName != "FFXII: The Zodiac Age") { + // return; + // } + + if (this.GameMemory == null) { + var game = Process.GetProcessesByName("FFXII_TZA").FirstOrDefault(); + if (game != null) { + this.GameMemory = new GameMemory(game); + } + } + + if (this.GameMemory != null) { + if (!this.GameMemory.Update()) { + this.GameMemory = null; + } + } + + if (this.GameMemory == null) { + this.Timer.CurrentState.IsGameTimePaused = false; + return; + } + + if (this.Timer.CurrentState.CurrentPhase is not (TimerPhase.NotRunning or TimerPhase.Ended) && this.GameMemory.Location.Current is 12 or 13) { + // if timer is started and at main menu or credits, never count as paused + this.Timer.CurrentState.IsGameTimePaused = false; + } else { + this.Timer.CurrentState.IsGameTimePaused = (this.GameMemory.IsLoaded.Current & 0x100) != 0; + } + + switch (this.Logic.Tick(this.Control, this.Timer.CurrentState.CurrentPhase, this.GameMemory)) { + case LogicResult.Start: + this.Timer.Start(); + break; + case LogicResult.Split: + this.Timer.Split(); + break; + case LogicResult.None: + default: + // do nothing + break; + } + } + + public override Control GetSettingsControl(LayoutMode mode) { + return this.Control; + } + + public override XmlNode GetSettings(XmlDocument document) { + return this.Control.GetSettings(document); + } + + public override void SetSettings(XmlNode settings) { + this.Control.SetSettings(settings); + } + + public override void Update(IInvalidator invalidator, LiveSplitState state, float width, float height, LayoutMode mode) { + } +} diff --git a/LiveSplit.TZA/TzaControl.Designer.cs b/LiveSplit.TZA/TzaControl.Designer.cs new file mode 100755 index 0000000..61964a0 --- /dev/null +++ b/LiveSplit.TZA/TzaControl.Designer.cs @@ -0,0 +1,78 @@ +using System.ComponentModel; + +namespace LiveSplit.TZA; + +partial class TzaControl { + /// + /// Required designer variable. + /// + private IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) { + if (disposing && (components != null)) { + components.Dispose(); + } + + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() { + this.components = new System.ComponentModel.Container(); + this.splitsPanel = new System.Windows.Forms.TreeView(); + this.splitsGroupBox = new System.Windows.Forms.GroupBox(); + this.toolTip = new System.Windows.Forms.ToolTip(this.components); + this.splitsGroupBox.SuspendLayout(); + this.SuspendLayout(); + // + // splitsPanel + // + this.splitsPanel.CheckBoxes = true; + this.splitsPanel.Dock = System.Windows.Forms.DockStyle.Fill; + this.splitsPanel.Location = new System.Drawing.Point(3, 16); + this.splitsPanel.Name = "splitsPanel"; + this.splitsPanel.Size = new System.Drawing.Size(283, 318); + this.splitsPanel.TabIndex = 1; + // + // splitsGroupBox + // + this.splitsGroupBox.AutoSize = true; + this.splitsGroupBox.Controls.Add(this.splitsPanel); + this.splitsGroupBox.Dock = System.Windows.Forms.DockStyle.Fill; + this.splitsGroupBox.Location = new System.Drawing.Point(10, 10); + this.splitsGroupBox.Name = "splitsGroupBox"; + this.splitsGroupBox.Size = new System.Drawing.Size(289, 337); + this.splitsGroupBox.TabIndex = 1; + this.splitsGroupBox.TabStop = false; + this.splitsGroupBox.Text = "Splits"; + // + // TzaControl + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.Controls.Add(this.splitsGroupBox); + this.Name = "TzaControl"; + this.Padding = new System.Windows.Forms.Padding(10); + this.Size = new System.Drawing.Size(309, 357); + this.splitsGroupBox.ResumeLayout(false); + this.ResumeLayout(false); + this.PerformLayout(); + } + + private System.Windows.Forms.ToolTip toolTip; + + private System.Windows.Forms.GroupBox splitsGroupBox; + + private System.Windows.Forms.TreeView splitsPanel; + + #endregion +} diff --git a/LiveSplit.TZA/TzaControl.cs b/LiveSplit.TZA/TzaControl.cs new file mode 100755 index 0000000..c8da6fa --- /dev/null +++ b/LiveSplit.TZA/TzaControl.cs @@ -0,0 +1,117 @@ +using System.Windows.Forms; +using System.Xml; +using LiveSplit.TZA.Splits; + +namespace LiveSplit.TZA; + +public partial class TzaControl : UserControl { + internal Dictionary EnabledSplits { get; } = new(); + private Dictionary CheckBoxes { get; } = new(); + private bool _updating; + + public TzaControl() { + this.InitializeComponent(); + this.SuspendLayout(); + this.Dock = DockStyle.Fill; + + this.splitsPanel.AfterCheck += (_, args) => { + if (this._updating) { + return; + } + + var isChecked = args.Node.Checked; + if (args.Node.Tag is SplitInfo info) { + // this is a parent node + if (isChecked) { + this.EnabledSplits[info.Id] = 0; + } else { + this.EnabledSplits.Remove(info.Id); + } + } else if (args.Node.Tag is int idx && args.Node.Parent.Tag is SplitInfo parentInfo) { + // this is a child node + if (isChecked) { + this.EnabledSplits[parentInfo.Id] = idx; + } else { + this.EnabledSplits.Remove(parentInfo.Id); + } + } + + this.UpdateCheckboxState(); + }; + + foreach (var info in SplitLogic.Splits) { + var node = new TreeNode($"({info.Splits.Length}) {info.Name}") { + Tag = info, + Checked = this.EnabledSplits.ContainsKey(info.Id), + }; + + for (var i = 0; i < info.Splits.Length; i++) { + var split = info.Splits[i]; + node.Nodes.Add(new TreeNode(split.GetHumanName()) { + Tag = i, + Checked = this.EnabledSplits.TryGetValue(info.Id, out var idx) && idx == i, + }); + } + + this.CheckBoxes[info.Id] = node; + this.splitsPanel.Nodes.Add(node); + } + + this.ResumeLayout(false); + this.PerformLayout(); + } + + internal XmlNode GetSettings(XmlDocument doc) { + var node = doc.CreateElement("Settings"); + + var enabledSplits = doc.CreateElement("EnabledSplits"); + foreach (var entry in this.EnabledSplits) { + var elem = doc.CreateElement("Split"); + elem.InnerText = (entry.Key * 1_000 + entry.Value).ToString(); + enabledSplits.AppendChild(elem); + } + + node.AppendChild(enabledSplits); + + return node; + } + + internal void SetSettings(XmlNode node) { + if (node is not XmlElement settings) { + return; + } + + this.EnabledSplits.Clear(); + + if (settings["EnabledSplits"] is { } enabledSplits) { + foreach (var split in enabledSplits.ChildNodes) { + if (split is not XmlElement splitElem) { + continue; + } + + if (uint.TryParse(splitElem.InnerText.Trim(), out var id)) { + var idx = id % 1_000; + this.EnabledSplits[id / 1_000] = (int) idx; + } + } + } + + this.UpdateCheckboxState(); + } + + private void UpdateCheckboxState() { + this._updating = true; + + foreach (var entry in this.CheckBoxes) { + entry.Value.Checked = this.EnabledSplits.ContainsKey(entry.Key); + + foreach (var node in entry.Value.Nodes) { + if (node is TreeNode child) { + child.Checked = this.EnabledSplits.TryGetValue(entry.Key, out var idx) && idx == (int) child.Tag; + } + } + } + + this._updating = false; + } +} diff --git a/LiveSplit.TZA/TzaControl.resx b/LiveSplit.TZA/TzaControl.resx new file mode 100755 index 0000000..8766f29 --- /dev/null +++ b/LiveSplit.TZA/TzaControl.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + \ No newline at end of file diff --git a/LiveSplit.TZA/TzaFactory.cs b/LiveSplit.TZA/TzaFactory.cs new file mode 100755 index 0000000..671934b --- /dev/null +++ b/LiveSplit.TZA/TzaFactory.cs @@ -0,0 +1,18 @@ +using LiveSplit.Model; +using LiveSplit.UI.Components; + +namespace LiveSplit.TZA; + +public class TzaFactory : IComponentFactory { + public string ComponentName => "FFXII: The Zodiac Age"; + public string Description => "Autosplitter for FFXII: The Zodiac Age"; + public ComponentCategory Category => ComponentCategory.Control; + public string UpdateName => this.ComponentName; + public string? XMLURL => null; + public string? UpdateURL => null; + public Version Version => new(1, 0); + + public IComponent Create(LiveSplitState state) { + return new TzaComponent(state); + } +} diff --git a/LiveSplit.TZA/Util/LocationNames.cs b/LiveSplit.TZA/Util/LocationNames.cs new file mode 100755 index 0000000..06aa239 --- /dev/null +++ b/LiveSplit.TZA/Util/LocationNames.cs @@ -0,0 +1,700 @@ +namespace LiveSplit.TZA.Util; + +internal static class LocationNames { + private static readonly Dictionary Names = new() { + [12] = "Main Menu", + [13] = "End Credits", + [48] = "Stockade", + [49] = "Arena", + [51] = "Oubliette", + [53] = "The Lightworks", + [54] = "Great Eastern Passage", + [55] = "Op Sector 36", + [56] = "Special Op Sector 3", + [57] = "Op Sector 37", + [58] = "Op Sector 29", + [61] = "Great Central Passage", + [62] = "The Zeviah Subterrane", + [66] = "North-South Junction", + [69] = "Terminus No. 4", + [70] = "Terminus No. 4 Adjunct", + [71] = "Terminus No. 7", + [124] = "Observation Parlour", + [125] = "Sky Saloon", + [126] = "Air Deck", + [129] = "South Bank Village", + [130] = "North Bank Village", + [132] = "The Yoma", + [133] = "Broken Sands", + [137] = "Uazcuff Hills", + [138] = "Sundered Earth", + [139] = "The Highlands", + [140] = "Fields of Eternity", + [141] = "The Shaded Path", + [142] = "The Chosen Path", + [144] = "The Skytrail", + [145] = "Realm of the Elder Dream", + [146] = "The Lost Way", + [147] = "Garden of Life's Circle", + [148] = "Oliphzak Rise", + [149] = "The Nameless Spring", + [151] = "The Omen-Spur", + [152] = "Trunkwall Road", + [153] = "Diverging Way", + [154] = "Sun-dappled Path", + [155] = "Garden of Decay", + [156] = "Path of Hours", + [157] = "Quietened Trace", + [158] = "Grand Bower", + [162] = "Corridor of Ages", + [163] = "Piebald Path", + [167] = "Greencrag", + [168] = "The Muted Scarp", + [169] = "Vale of Lingering Sorrow", + [170] = "Hope's Reach", + [171] = "Echoes of the Past", + [175] = "The Slumbermead", + [176] = "Succor Midst Sorrow", + [177] = "The Fog Mutters", + [178] = "Overlooking Eternity", + [179] = "Lifeless Strand", + [180] = "Field of the Fallen Lord", + [184] = "Falls of Time", + [185] = "Mirror of the Soul", + [186] = "The Acolyte's Burden", + [187] = "Doubt Abandoned", + [188] = "Skybent Chamber", + [192] = "Destiny's March", + [193] = "Temptation Eluded", + [194] = "Chamber of the Chosen", + [198] = "Hall of Shadowlight", + [200] = "Hall of Lambent Darkness", + [202] = "Hall of the Wroth God", + [209] = "Southern Skirts", + [210] = "Summit Path", + [215] = "Rays of Ashen Light", + [216] = "Empyrean Way", + [217] = "Skyreach Ridge", + [218] = "Trail of Sky-flung Stone", + [219] = "Northern Skirts", + [220] = "Halny Crossing", + [223] = "Empyrean Seat", + [227] = "The Stepping", + [228] = "Yardang Labyrinth", + [229] = "Sand-swept Naze", + [230] = "Banks of the Nebra", + [231] = "Passage Entrance", + [232] = "Murmuring Defile", + [233] = "Outpost", + [236] = "Throne Road", + [237] = "Warrior's Wash", + [238] = "Gizas North Bank", + [239] = "Toam Hills", + [243] = "Nomad Village", + [247] = "Starfall Field", + [248] = "Crystal Glade", + [249] = "Gizas South Bank", + [253] = "Throne Road", + [254] = "Warrior's Wash", + [255] = "Gizas North Bank", + [256] = "Toam Hills", + [260] = "Nomad Village", + [264] = "Starfall Field", + [265] = "Crystal Glade", + [266] = "Gizas South Bank", + [270] = "Tracks of the Beast", + [274] = "Aerial Gardens", + [275] = "Inner Ward", + [279] = "Lower Apartments", + [280] = "Upper Apartments", + [282] = "The Highhall", + [286] = "Energy Transitarium", + [289] = "North End", + [290] = "Muthru Bazaar", + [291] = "East End", + [292] = "Southern Plaza", + [295] = "Amal's Weaponry", + [296] = "Panamis's Protectives", + [297] = "Migelo's Sundries", + [298] = "Yugri's Magicks", + [299] = "Batahn's Technicks", + [300] = "Yamoora's Gambits", + [301] = "Samalzalam Manor", + [302] = "The Clan Hall", + [304] = "The Sandsea", + [305] = "Eastgate", + [306] = "Southgate", + [307] = "Westgate", + [311] = "Central Spur Stairs", + [312] = "East Waterway Control", + [313] = "North Spur Sluiceway", + [314] = "East Spur Stairs", + [315] = "Northern Sluiceway", + [316] = "East Waterway Control", + [318] = "No. 11 Channel", + [319] = "East Sluice Control", + [320] = "West Sluice Control", + [321] = "No. 10 Channel", + [322] = "Central Waterway Control", + [326] = "Southern Sluiceway", + [329] = "Overflow Cloaca", + [332] = "A Vikaari Bhrum", + [333] = "Trahk Pis Praa", + [334] = "Sthaana Pisces", + [335] = "Dha Vikaari Jula", + [336] = "Trahk Jilaam Praa'dii", + [337] = "Sthaana Aries", + [341] = "Crystal Core", + [342] = "No. 1 Cloaca", + [345] = "Galtea Downs", + [346] = "Corridor of Sand", + [347] = "Shimmering Horizons", + [348] = "The Midfault", + [349] = "Windtrace Dunes", + [350] = "The Western Divide", + [354] = "Wyrm's Nest", + [357] = "Shaft Entry", + [358] = "Oltam Span", + [359] = "Transitway 1", + [360] = "Transitway 2", + [365] = "Shunia Twinspan", + [366] = "Site 2", + [367] = "Site 3", + [369] = "Tasche Span", + [372] = "Site 9", + [374] = "Site 11", + [376] = "Site 7", + [379] = "Platform 1 - East Tanks", + [380] = "Platform 1 - Refinery", + [381] = "East Junction", + [382] = "Primary Tank Complex", + [383] = "Central Junction", + [384] = "Platform 1 - South Tanks", + [385] = "Platform 2 - Refinery", + [386] = "Yensa Border Tunnel", + [387] = "South Tank Approach", + [390] = "The Urutan-Yensa Sea", + [391] = "Withering Shores", + [392] = "Augur Hill", + [393] = "Yellow Sands", + [394] = "The Sandscale Bank", + [398] = "Demesne of the Sandqueen", + [399] = "Trail of Fading Warmth", + [400] = "Simoon Bluff", + [403] = "Valley of the Dead", + [406] = "Hall of the Destroyer", + [407] = "Hall of the Sentinel", + [410] = "Royal Passage", + [411] = "Southfall Passage", + [412] = "Northfall Passage", + [415] = "Cloister of Flame", + [418] = "Chamber of First Light", + [421] = "Hall of Effulgent Light", + [422] = "Cloister of Distant Song", + [426] = "Cloister of the Highborn", + [430] = "Hall of the Ivory Covenant", + [431] = "Hall of Slumbering Might", + [438] = "The Crucible", + [441] = "Cloister of Solace", + [444] = "Cloister of Reason", + [447] = "Paths of Chained Light", + [448] = "The Needlebrake", + [449] = "Whisperleaf Way", + [450] = "The Parting Glade", + [453] = "The Rustling Chapel", + [456] = "Dell of the Dreamer", + [459] = "The Branchway", + [460] = "The Greenswathe", + [463] = "Fading Vale", + [464] = "Head of the Silverflow", + [465] = "Freezing Gorge", + [468] = "Frozen Brook", + [469] = "Icebound Flow", + [470] = "Karydine Glacier", + [473] = "Path of the Firstfall", + [474] = "Spine of the Icewyrm", + [475] = "Silverflow's End", + [478] = "The Reseta Strand", + [479] = "Pora-Pora Sands", + [480] = "The Mauleia Strand", + [481] = "Cape Uahuk", + [482] = "Cape Tialan", + [483] = "Kaukula Pass", + [484] = "The Hakawea Shore", + [488] = "Hunters' Camp", + [491] = "Caima Hills", + [492] = "The Vaddu Strand", + [493] = "Limatra Hills", + [494] = "Rava's Pass", + [497] = "Old Elanise Road", + [498] = "Crossfield", + [499] = "The Terraced Bank", + [500] = "Journey's Rest", + [503] = "North Liavell Hills", + [504] = "South Liavell Hills", + [505] = "Feddik River", + [506] = "The Northsward", + [509] = "Footfalls of the Past", + [511] = "Echoes from Time's Garden", + [517] = "City of Other Days", + [518] = "Path of Hidden Blessing", + [522] = "They Who Thirst Not", + [525] = "Field of Fallen Wings", + [526] = "The Switchback", + [527] = "Haulo Green", + [530] = "Dagan Flats", + [531] = "Field of Light Winds", + [532] = "The Greensnake", + [533] = "Sunlit Path", + [536] = "The Shred", + [539] = "Walk of Flitting Rifts", + [540] = "Walk of Stolen Truths", + [541] = "Walk of Dancing Shadow", + [542] = "Antiquity's End", + [545] = "Redolent Glade", + [548] = "White Magick's Embrace", + [549] = "Ice Field of Clearsight", + [550] = "The Edge of Reason", + [552] = "Port Launch", + [555] = "Port Section", + [556] = "Large Freight Stores", + [557] = "Starboard Section", + [558] = "Sub-control Room", + [561] = "Airship Berth Access", + [564] = "Central Brig Access", + [567] = "Cellar Stores", + [568] = "Cellars", + [569] = "Lower Halls", + [570] = "Secret Passage", + [571] = "Treasure Room No. 8", + [572] = "The Garden Stairs", + [576] = "Invitation to Heresy", + [577] = "Sandfalls", + [578] = "Hourglass Basin", + [581] = "The Undershore", + [582] = "Halls of Ardent Darkness", + [585] = "The Balamka Fault", + [586] = "Drybeam Cavern", + [587] = "Darkened Wharf", + [588] = "Canopy of Clay", + [590] = "Athroza Quicksands", + [593] = "Walk of Sky", + [594] = "Walk of Mind", + [597] = "Ward of Measure", + [598] = "Cold Distance", + [599] = "Walk of Prescience", + [600] = "Walk of Reason", + [603] = "Ward of Steel", + [606] = "Ward of Velitation", + [607] = "Walk of Torn Illusion", + [608] = "Walk of Revelation", + [609] = "Ward of the Sword-King", + [612] = "Hall of Worth", + [615] = "Vault of the Champion", + [618] = "Throne of Veiled Gods", + [621] = "A Prama Vikaari", + [622] = "Kabonii Jilaam Pratii'vaa", + [623] = "Kabonii Jilaam Avaa", + [624] = "Dha Vikaari Bhrum", + [625] = "Sthaana Scorpio", + [626] = "A Vikaari Dhebon", + [630] = "West Barbican", + [631] = "Jajim Bazaar", + [632] = "West Ward", + [635] = "Grand Arcade", + [636] = "Highgarden Terrace", + [640] = "Molberry", + [641] = "Trant", + [642] = "Charlotte's Magickery", + [643] = "Bulward's Technicks", + [666] = "Womb of the Sun-cryst", + [668] = "Womb of the Sun-cryst", + [670] = "Heaven's Challenge", + [682] = "Hell's Challenge", + [686] = "Gate of Earth", + [687] = "Gate of Water", + [690] = "The Trimahla Water-Steps", + [691] = "The Aadha Water-Steps", + [694] = "The Haalmikah Water-Steps", + [695] = "Gate of Fire", + [698] = "Gate of Wind", + [701] = "North Sprawl", + [702] = "South Sprawl", + [703] = "Dalan's House", + [704] = "Residence", + [709] = "The Black Watch", + [710] = "The Confiscatory", + [712] = "North Entrance", + [713] = "Pithead Junction A", + [714] = "Phase 1 Shaft", + [715] = "Phase 1 Dig", + [716] = "Crossover A", + [717] = "Pithead Junction B", + [718] = "Staging Shaft", + [719] = "Crossover B", + [722] = "Ore Separation", + [725] = "Phase 2 Dig", + [726] = "Crossover C", + [727] = "Pithead Junction C", + [728] = "Phase 2 Shaft", + [730] = "Special Charter Shaft", + [733] = "Special Charter Dig", + [736] = "Hall of the Light", + [737] = "Hall of the Light", + [738] = "Temple Grounds", + [741] = "Temple Approach", + [742] = "Sand-strewn Pass", + [745] = "Nilbasse", + [746] = "Rienna", + [747] = "Vint's Armaments", + [748] = "Granch's Requisites", + [749] = "Lebleu's Gambits", + [751] = "Banks of the Sogoht", + [752] = "Lull of the Land", + [753] = "The Elderknoll", + [756] = "Tsenoble", + [762] = "Alley of Muted Sighs", + [763] = "Alley of Low Whispers", + [766] = "Fane of the Path", + [767] = "The Spiritwood", + [768] = "Road of Verdant Praise", + [774] = "Periphery", + [775] = "Catwalk", + [776] = "Antechamber", + [777] = "Antechamber", + [779] = "Central Lift", + [782] = "Central Shaft", + [785] = "Cannon Superstructure", + [788] = "Aerodrome (Rabanastre)", + [791] = "Aerodrome (Bhujerba)", + [794] = "Aerodrome (Archades)", + [797] = "Aerodrome (Balfonheim Port)", + [800] = "Aerodrome (Nalbina Town)", + [803] = "Travica Way", + [804] = "Cloudborne Row", + [805] = "Miners' End", + [806] = "Lhusu Square", + [809] = "Khus Skygrounds", + [810] = "Kaff Terrace", + [813] = "Targe's Arms", + [814] = "Rithil's Protectives", + [816] = "Mait's Magicks", + [817] = "Clio's Technicks", + [818] = "Bashketi's Gambits", + [819] = "The Staras Residence", + [820] = "The Cloudborne", + [823] = "Sea Breeze Lane", + [824] = "Gallerina Marketplace", + [825] = "Quayside Court", + [826] = "Saccio Lane", + [827] = "Chivany Breakwater", + [828] = "Canal Lane", + [833] = "Beruny's Armaments", + [834] = "Odo's Technicks", + [835] = "Port Villa", + [836] = "The Whitecap", + [837] = "Port Villa", + [838] = "No. 11 Channel", + [839] = "No. 11 Channel", + [840] = "East Sluice Control", + [841] = "Southern Sluiceway", + [842] = "West Sluice Control", + [843] = "No. 10 Channel", + [844] = "No. 10 Channel", + [845] = "No. 3 Cloaca Spur", + [846] = "No. 3 Cloaca Spur", + [847] = "No. 1 Cloaca", + [848] = "No. 4 Cloaca Spur", + [849] = "No. 4 Cloaca Spur", + [850] = "Central Waterway Control", + [854] = "C.D.B.", + [856] = "Dalan's Marker", + [871] = "Bridge", + [872] = "Battle Launch", + [876] = "Brig No. 1", + [888] = "Crystal Peak", + [891] = "East-West Bypass", + [892] = "The Zeviah Span", + [893] = "West Annex", + [894] = "Terminus No. 7 Adjunct", + [895] = "Special Op Sector 5", + [898] = "Colosseum", + [901] = "Lasche Span", + [902] = "Site 5", + [903] = "Site 6 South", + [904] = "Site 6 North", + [905] = "Staging Area", + [908] = "Living Chasm", + [916] = "Siti Bhrusuna", + [926] = "A Vikaari Kabonii", + [927] = "Sthaana Cancer", + [928] = "Bhrum Pis Avaa", + [929] = "Bhrum Pis Pratii", + [930] = "Dha Vikaari Trahk", + [933] = "Site 2", + [934] = "Shunia Twinspan", + [935] = "Transitway 1", + [936] = "Oltam Span", + [939] = "Dha Vikaari Kabonii", + [940] = "A Vikaari Kanbhru Ra", + [941] = "Dhebon Jilaam Praa'dii", + [942] = "Dhebon Jilaam Pratii'dii", + [943] = "Sthaana Sagittarius", + [944] = "A Vikaari Sirhru Pratii", + [945] = "A Vikaari Sirhru Praa", + [946] = "Dhebon Jilaam Avaapratii", + [947] = "A Vikaari Sirhru Si", + [949] = "Dhebon Jilaam Avaa", + [952] = "Dha Vikaari Dhebon Praa", + [953] = "Dha Vikaari Dhebon Pratii", + [954] = "Sirhru Phullam Praa", + [955] = "Sthaana Leo", + [956] = "Sirhru Phullam Praa'vaa", + [957] = "Sirhru Phullam Pratii'vaa", + [958] = "Sthaana Gemini", + [959] = "Sirhru Phullam Udiipratii", + [960] = "Sirhru Jilaam Praa'dii", + [961] = "Sirhru Jilaam Pratii'dii", + [962] = "Sirhru Jilaam Praa", + [963] = "Sirhru Jilaam Pratii", + [964] = "Sirhru Jilaam Praa'vaa", + [965] = "Sirhru Jilaam Pratii'vaa", + [966] = "Sirhru Pis Praa", + [967] = "Sirhru Pis Pratii", + [968] = "Sirhru Pis Avaa", + [969] = "Sirhru Jilaam Avaapratii", + [970] = "Sirhru Jilaam Avaapraa", + [971] = "A Vikaari Uldobi", + [972] = "A Vikaari Uldobi Si", + [973] = "Dha Vikaari Dhebon Si", + [979] = "Dha Vikaari Sirhru", + [980] = "Sthaana Virgo", + [981] = "Uldobi Jilaam Praa'dii", + [982] = "Uldobi Jilaam Pratii", + [983] = "Uldobi Jilaam Praa", + [984] = "Uldobi Phullam Pratii'dii", + [985] = "Sthaana Capricorn", + [986] = "Dha Vikaari Sirhru Si", + [987] = "Uldobi Phullam Udiipraa", + [988] = "Uldobi Phullam Pratii'vaa", + [989] = "Uldobi Phullam Praa'vaa", + [990] = "Uldobi Phullam Pratii", + [991] = "Sthaana Taurus", + [992] = "Sthaana Libra", + [993] = "Uldobi Jilaam Praa'vaa", + [994] = "Uldobi Jilaam Pratii'vaa", + [995] = "Uldobi Jilaam Avaa", + [996] = "A Vikaari Kanbhru", + [1002] = "Dha Vikaari Uldobi", + [1003] = "Kanbhru Pis", + [1004] = "Dha Vikaari Dhebon Ra", + [1005] = "Sthaana Aquarius", + [1008] = "66th Floor", + [1009] = "Rm 6613 West", + [1010] = "Rm 6613 East", + [1011] = "Rm 6612 West", + [1013] = "Rm 6611 West", + [1014] = "Rm 6611 East", + [1015] = "Rm 6602 West", + [1016] = "Rm 6601 West", + [1017] = "Rm 6602 East", + [1018] = "Rm 6601 East", + [1019] = "67th Floor", + [1020] = "Rm 6711 West", + [1021] = "Rm 6711 East", + [1023] = "Rm 6703 West", + [1024] = "Rm 6704 East", + [1025] = "Rm 6703 East", + [1026] = "Rm 6702 West", + [1027] = "Rm 6701 West", + [1028] = "Rm 6702 East", + [1030] = "68th Floor", + [1031] = "69th Floor", + [1036] = "66th Floor", + [1037] = "Rm 6613 West", + [1038] = "Rm 6613 East", + [1039] = "Rm 6612 West", + [1041] = "Rm 6611 West", + [1042] = "Rm 6611 East", + [1043] = "Rm 6602 West", + [1044] = "Rm 6601 West", + [1045] = "Rm 6602 East", + [1046] = "Rm 6601 East", + [1047] = "70th Floor", + [1048] = "Rm 7002 West", + [1049] = "Rm 7001 West", + [1050] = "Rm 7002 East", + [1054] = "67th Floor", + [1055] = "Rm 6711 West", + [1056] = "Rm 6711 East", + [1058] = "Rm 6703 West", + [1059] = "Rm 6704 East", + [1060] = "Rm 6703 East", + [1061] = "Rm 6702 West", + [1062] = "Rm 6701 West", + [1063] = "Rm 6702 East", + [1067] = "68th Floor", + [1068] = "Rm 6814 West", + [1069] = "Rm 6814 East", + [1070] = "Rm 6813 West", + [1071] = "Rm 6813 East", + [1072] = "Rm 6812 West", + [1073] = "Rm 6812 East", + [1074] = "Rm 6811 West", + [1075] = "Rm 6811 East", + [1076] = "Rm 6804 West", + [1077] = "Rm 6803 West", + [1078] = "Rm 6804 East", + [1079] = "Rm 6803 East", + [1080] = "Rm 6802 West", + [1081] = "Rm 6801 West", + [1082] = "Rm 6802 East", + [1083] = "Rm 6801 East", + [1088] = "Rm 6912 East", + [1089] = "Rm 6911 West", + [1091] = "Rm 6904 West", + [1094] = "Rm 6903 East", + [1095] = "Rm 6902 West", + [1096] = "Rm 6901 West", + [1098] = "Rm 6901 East", + [1101] = "The Wellspring", + [1102] = "Horizon's Break", + [1103] = "The Reach", + [1104] = "Reach of the Damned", + [1105] = "Reach of the Occult", + [1111] = "Wellspring Labyrinth", + [1113] = "Dunes of Profaning Wind", + [1114] = "Blackrock Vault", + [1115] = "Wellspring Ravel - 1st Flight", + [1116] = "Wellspring Ravel - 2nd Flight", + [1118] = "Wellspring Ravel - 3rd Flight", + [1119] = "Wellspring Ravel - 4th Flight", + [1121] = "Marsh of Profaning Wind", + [1122] = "Horizon's Cusp", + [1123] = "Penumbra - Interior", + [1124] = "Penumbra - North", + [1125] = "Penumbra - South", + [1126] = "Umbra - Interior", + [1127] = "Umbra - North", + [1128] = "Umbra - South", + [1129] = "Abyssal - Interior", + [1130] = "Abyssal - North", + [1131] = "Abyssal - South", + [1132] = "Cleft of Profaning Wind", + [1133] = "The Bounds of Truth", + [1134] = "Station of Banishment", + [1136] = "Station of Suffering", + [1138] = "Station of Ascension", + [1140] = "Spire Ravel - 1st Flight", + [1141] = "Spire Ravel - 2nd Flight", + [1143] = "Empyrean Ravel", + [1145] = "Overflow Cloaca", + [1150] = "Air Deck", + [1151] = "Babbling Vale", + [1153] = "Withering Shores", + [1155] = "Stage 1", + [1156] = "Stage 2", + [1157] = "Stage 3", + [1158] = "Stage 4", + [1159] = "Stage 5", + [1163] = "Stage 6", + [1164] = "Stage 7", + [1165] = "Stage 8", + [1166] = "Stage 9", + [1167] = "Stage 10", + [1171] = "Stage 11", + [1172] = "Stage 12", + [1173] = "Stage 13", + [1174] = "Stage 14", + [1175] = "Stage 15", + [1179] = "Stage 16", + [1180] = "Stage 17", + [1181] = "Stage 18", + [1182] = "Stage 19", + [1183] = "Stage 20", + [1187] = "Stage 21", + [1188] = "Stage 22", + [1189] = "Stage 23", + [1190] = "Stage 24", + [1191] = "Stage 25", + [1195] = "Stage 26", + [1196] = "Stage 27", + [1197] = "Stage 28", + [1198] = "Stage 29", + [1199] = "Stage 30", + [1203] = "Stage 31", + [1204] = "Stage 32", + [1205] = "Stage 33", + [1206] = "Stage 34", + [1207] = "Stage 35", + [1211] = "Stage 36", + [1212] = "Stage 37", + [1213] = "Stage 38", + [1214] = "Stage 39", + [1215] = "Stage 40", + [1219] = "Stage 41", + [1220] = "Stage 42", + [1221] = "Stage 43", + [1222] = "Stage 44", + [1223] = "Stage 45", + [1227] = "Stage 46", + [1228] = "Stage 47", + [1229] = "Stage 48", + [1230] = "Stage 49", + [1231] = "Stage 50", + [1235] = "Stage 51", + [1236] = "Stage 52", + [1237] = "Stage 53", + [1238] = "Stage 54", + [1239] = "Stage 55", + [1243] = "Stage 56", + [1244] = "Stage 57", + [1245] = "Stage 58", + [1246] = "Stage 59", + [1247] = "Stage 60", + [1251] = "Stage 61", + [1252] = "Stage 62", + [1253] = "Stage 63", + [1254] = "Stage 64", + [1255] = "Stage 65", + [1259] = "Stage 66", + [1260] = "Stage 67", + [1261] = "Stage 68", + [1262] = "Stage 69", + [1263] = "Stage 70", + [1267] = "Stage 71", + [1268] = "Stage 72", + [1269] = "Stage 73", + [1270] = "Stage 74", + [1271] = "Stage 75", + [1275] = "Stage 76", + [1276] = "Stage 77", + [1277] = "Stage 78", + [1278] = "Stage 79", + [1279] = "Stage 80", + [1283] = "Stage 81", + [1284] = "Stage 82", + [1285] = "Stage 83", + [1286] = "Stage 84", + [1287] = "Stage 85", + [1291] = "Stage 86", + [1292] = "Stage 87", + [1293] = "Stage 88", + [1294] = "Stage 89", + [1295] = "Stage 90", + [1299] = "Stage 91", + [1300] = "Stage 92", + [1301] = "Stage 93", + [1302] = "Stage 94", + [1303] = "Stage 95", + [1307] = "Stage 96", + [1308] = "Stage 97", + [1309] = "Stage 98", + [1310] = "Stage 99", + [1311] = "Stage 100", + }; + + internal static string Get(ushort location) { + return Names.TryGetValue(location, out var name) ? name : "???"; + } +}