From b84c6adcb4570d3c973f86a7dc72363f546bea16 Mon Sep 17 00:00:00 2001 From: Jacob Keller Date: Fri, 2 Jan 2026 15:47:47 -0800 Subject: [PATCH 1/4] raidemulator: refactor analyze() for better caching The raid emulator analyzes an encounter by iterating over each party member and analyzing the log. For encounters with many players, this can take a long time, and appears to result in poor caching and JIT performance from browser javascript engines. Refactor the analyze() and analyzeFor() functions. Pass a list of party member IDs to analyze at the same time. Modify the analyzeFor() function to build a context for each player with its own popupText. This allows iterating over the line once and analyzing it for each party member instead of iterating over the entire log file many times. This appears to behave more favorably for caching and JIT purposes. To limit memory usage, slice the party member array by chunks of 24. This will prevent extremely large encounters (such as 72-man CEs or forked tower) from overwhelming the browser. Additionally, ensure that all popupText always use the same regexCache. Remove the ability for the popupText to ever allocate its own cache, and instead require it as a constructor argument. Allocate a single regexCache in analyze() and pass this in. This prevents accidentally generating multiple regex caches and simplifies the logic for handling it all around. This appears to be significantly faster on my setup for large encounters. Potentially the batch size could be tweaked if its too large or eliminated altogether if it turns out to be unnecessary. For now the player context is a simple type but we could potentially refactor it into a full class too. --- .../emulator/data/AnalyzedEncounter.ts | 206 ++++++++++-------- .../emulator/data/PopupTextAnalysis.ts | 3 +- 2 files changed, 119 insertions(+), 90 deletions(-) diff --git a/ui/raidboss/emulator/data/AnalyzedEncounter.ts b/ui/raidboss/emulator/data/AnalyzedEncounter.ts index 300f5c64d82..9487f875ef7 100644 --- a/ui/raidboss/emulator/data/AnalyzedEncounter.ts +++ b/ui/raidboss/emulator/data/AnalyzedEncounter.ts @@ -33,7 +33,6 @@ type Perspectives = { [id: string]: Perspective }; export default class AnalyzedEncounter extends EventBus { perspectives: Perspectives = {}; - regexCache: LineRegExpCache | undefined; constructor( public options: RaidbossOptions, public encounter: Encounter, @@ -110,24 +109,43 @@ export default class AnalyzedEncounter extends EventBus { }); } + checkPartyMember(id: string): boolean { + const partyMember = this.encounter.combatantTracker?.combatants[id]; + + if (!partyMember) + return false; + + const initState = partyMember?.nextState(0); + + if (initState.Job === 0) { + this.perspectives[id] = { + initialData: {}, + triggers: [], + }; + return false; + } + + return true; + } + async analyze(): Promise { - // @TODO: Make this run in parallel sometime in the future, since it could be really slow? + const regexCache: LineRegExpCache = new Map(); + if (this.encounter.combatantTracker) { - for (const id of this.encounter.combatantTracker.partyMembers) - await this.analyzeFor(id); - } + const partyMembers = this.encounter.combatantTracker.partyMembers; + const batchSize = 24; - // Free up this memory - delete this.regexCache; + for (let i = 0; i < partyMembers.length; i += batchSize) { + const batch = partyMembers.slice(i, i + batchSize); + await this.analyzeFor(batch, regexCache); + } + } return this.dispatch('analyzed'); } - async analyzeFor(id: string): Promise { - if (!this.encounter.combatantTracker) - return; + async analyzeFor(partyMembers: string[], regexCache: LineRegExpCache): Promise { let currentLogIndex = 0; - const partyMember = this.encounter.combatantTracker.combatants[id]; const getCurLogLine = (): LineEvent => { const line = this.encounter.logLines[currentLogIndex]; @@ -136,18 +154,7 @@ export default class AnalyzedEncounter extends EventBus { return line; }; - if (!partyMember) - return; - - const initState = partyMember?.nextState(0); - - if (initState.Job === 0) { - this.perspectives[id] = { - initialData: {}, - triggers: [], - }; - return; - } + const validPartyMembers = partyMembers.filter((id) => this.checkPartyMember(id)); const timelineUI = new RaidEmulatorAnalysisTimelineUI(this.options); const timelineController = new RaidEmulatorTimelineController( @@ -157,79 +164,94 @@ export default class AnalyzedEncounter extends EventBus { ); timelineController.bindTo(this.emulator); - const popupText = new PopupTextAnalysis( - this.options, - new TimelineLoader(timelineController), - raidbossFileData, - ); + type PlayerContext = { + popupText: PopupTextAnalysis; + id: string; + }; - if (this.regexCache) - popupText.regexCache = this.regexCache; + const partyContext: PlayerContext[] = validPartyMembers.map((id) => { + const popupText = new PopupTextAnalysis( + this.options, + new TimelineLoader(timelineController), + raidbossFileData, + regexCache, + ); - const generator = new PopupTextGenerator(popupText); - timelineUI.SetPopupTextInterface(generator); + const generator = new PopupTextGenerator(popupText); + timelineUI.SetPopupTextInterface(generator); - timelineController.SetPopupTextInterface(generator); + timelineController.SetPopupTextInterface(generator); - this.selectPerspective(id, popupText); + if (timelineController.activeTimeline?.ui) { + timelineController.activeTimeline.ui.OnTrigger = (trigger: LooseTrigger, matches) => { + const currentLine = this.encounter.logLines[currentLogIndex]; + if (!currentLine) + throw new UnreachableCode(); - if (timelineController.activeTimeline?.ui) { - timelineController.activeTimeline.ui.OnTrigger = (trigger: LooseTrigger, matches) => { - const currentLine = this.encounter.logLines[currentLogIndex]; - if (!currentLine) + const resolver = popupText.currentResolver = new Resolver({ + initialData: EmulatorCommon.cloneData(popupText.getData()), + suppressed: false, + executed: false, + }); + resolver.triggerHelper = popupText._onTriggerInternalGetHelper( + trigger, + matches?.groups ?? {}, + currentLine?.timestamp, + ); + popupText.triggerResolvers.push(resolver); + + popupText.OnTrigger(trigger, matches, currentLine.timestamp); + + resolver.setFinal(() => { + // Get the current log line when the callback is executed instead of the line + // when the trigger initially fires + const resolvedLine = getCurLogLine(); + resolver.status.finalData = EmulatorCommon.cloneData(popupText.getData()); + delete resolver.triggerHelper?.resolver; + if (popupText.callback) { + popupText.callback( + resolvedLine, + resolver.triggerHelper, + resolver.status, + popupText.getData(), + ); + } + }); + }; + } + + popupText.callback = (log, triggerHelper, currentTriggerStatus) => { + const perspective = this.perspectives[id]; + if (!perspective || !triggerHelper) throw new UnreachableCode(); - const resolver = popupText.currentResolver = new Resolver({ - initialData: EmulatorCommon.cloneData(popupText.getData()), - suppressed: false, - executed: false, - }); - resolver.triggerHelper = popupText._onTriggerInternalGetHelper( - trigger, - matches?.groups ?? {}, - currentLine?.timestamp, - ); - popupText.triggerResolvers.push(resolver); - - popupText.OnTrigger(trigger, matches, currentLine.timestamp); - - resolver.setFinal(() => { - // Get the current log line when the callback is executed instead of the line - // when the trigger initially fires - const resolvedLine = getCurLogLine(); - resolver.status.finalData = EmulatorCommon.cloneData(popupText.getData()); - delete resolver.triggerHelper?.resolver; - if (popupText.callback) { - popupText.callback( - resolvedLine, - resolver.triggerHelper, - resolver.status, - popupText.getData(), - ); - } + perspective.triggers.push({ + triggerHelper: triggerHelper, + status: currentTriggerStatus, + logLine: log, + resolvedOffset: log.timestamp - this.encounter.startTimestamp, }); }; - } + popupText.triggerResolvers = []; - popupText.callback = (log, triggerHelper, currentTriggerStatus) => { - const perspective = this.perspectives[id]; - if (!perspective || !triggerHelper) - throw new UnreachableCode(); + this.selectPerspective(id, popupText); - perspective.triggers.push({ - triggerHelper: triggerHelper, - status: currentTriggerStatus, - logLine: log, - resolvedOffset: log.timestamp - this.encounter.startTimestamp, - }); - }; - popupText.triggerResolvers = []; + return { + popupText: popupText, + id: id, + }; + }); - this.perspectives[id] = { - initialData: EmulatorCommon.cloneData(popupText.getData(), []), - triggers: [], - finalData: popupText.getData(), - }; + for (const ctx of partyContext) { + const popupText = ctx.popupText; + const id = ctx.id; + + this.perspectives[id] = { + initialData: EmulatorCommon.cloneData(popupText.getData(), []), + triggers: [], + finalData: popupText.getData(), + }; + } for (; currentLogIndex < this.encounter.logLines.length; ++currentLogIndex) { const log = this.encounter.logLines[currentLogIndex]; @@ -237,18 +259,24 @@ export default class AnalyzedEncounter extends EventBus { throw new UnreachableCode(); await this.dispatch('analyzeLine', log); - const combatant = this.encounter?.combatantTracker?.combatants[id]; + for (const ctx of partyContext) { + const popupText = ctx.popupText; + const id = ctx.id; + + const combatant = this.encounter?.combatantTracker?.combatants[id]; + + if (combatant && combatant.hasState(log.timestamp)) { + this.updateState(combatant, log.timestamp, popupText); + } - if (combatant && combatant.hasState(log.timestamp)) - this.updateState(combatant, log.timestamp, popupText); + await popupText.onEmulatorLog([log], getCurLogLine); + } this.watchCombatantsOverride.tick(log.timestamp); - await popupText.onEmulatorLog([log], getCurLogLine); timelineController.onEmulatorLogEvent([log]); } this.watchCombatantsOverride.clear(); timelineUI.stop(); - this.regexCache = popupText.regexCache; } } diff --git a/ui/raidboss/emulator/data/PopupTextAnalysis.ts b/ui/raidboss/emulator/data/PopupTextAnalysis.ts index 5a398bf0641..c5cd5b3d0de 100644 --- a/ui/raidboss/emulator/data/PopupTextAnalysis.ts +++ b/ui/raidboss/emulator/data/PopupTextAnalysis.ts @@ -103,9 +103,10 @@ export default class PopupTextAnalysis extends StubbedPopupText { options: RaidbossOptions, timelineLoader: TimelineLoader, raidbossFileData: RaidbossFileData, + regexCache: LineRegExpCache, ) { super(options, timelineLoader, raidbossFileData); - this.regexCache = new Map(); + this.regexCache = regexCache; this.ttsSay = (_text: string) => { return; }; From 3dc636a62eb3a42cfc7395c702abd2137835a094 Mon Sep 17 00:00:00 2001 From: Jacob Keller Date: Sun, 4 Jan 2026 01:32:35 -0800 Subject: [PATCH 2/4] raidemulator: log time it takes to analyze encounters --- ui/raidboss/emulator/data/RaidEmulator.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/raidboss/emulator/data/RaidEmulator.ts b/ui/raidboss/emulator/data/RaidEmulator.ts index 80e42090620..9158e7f7651 100644 --- a/ui/raidboss/emulator/data/RaidEmulator.ts +++ b/ui/raidboss/emulator/data/RaidEmulator.ts @@ -45,7 +45,10 @@ export default class RaidEmulator extends EventBus { this.currentEncounter = new AnalyzedEncounter(this.options, enc, this, watchCombatantsOverride); void this.dispatch('preCurrentEncounterChanged', this.currentEncounter); + const start = performance.now(); void this.currentEncounter.analyze().then(() => { + const duration = performance.now() - start; + console.log(`Analyzing encounter took ${duration.toFixed(2)}ms`); void this.dispatch('currentEncounterChanged', this.currentEncounter); }); } From c9188d5d8b89565df69701da060a809db9873415 Mon Sep 17 00:00:00 2001 From: Jacob Keller Date: Sun, 4 Jan 2026 01:34:55 -0800 Subject: [PATCH 3/4] raidemulator: reduce memory usage for data storage Reduce the size of the initialData and finalData objects by eliminating some keys from the RaidbossData type. --- .../emulator/data/PopupTextAnalysis.ts | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/ui/raidboss/emulator/data/PopupTextAnalysis.ts b/ui/raidboss/emulator/data/PopupTextAnalysis.ts index c5cd5b3d0de..4fd8efd6f3b 100644 --- a/ui/raidboss/emulator/data/PopupTextAnalysis.ts +++ b/ui/raidboss/emulator/data/PopupTextAnalysis.ts @@ -1,4 +1,5 @@ import { UnreachableCode } from '../../../../resources/not_reached'; +import { RaidbossData } from '../../../../types/data'; import { EventResponses, LogEvent } from '../../../../types/event'; import { Matches } from '../../../../types/net_matches'; import { LooseTrigger, RaidbossFileData } from '../../../../types/trigger'; @@ -15,7 +16,7 @@ type ResolverFunc = () => void; export interface ResolverStatus { responseType?: string; responseLabel?: string; - initialData: DataType; + initialData?: DataType; finalData?: DataType; condition?: boolean; response?: undefined; @@ -30,6 +31,30 @@ type EmulatorTriggerHelper = TriggerHelper & { resolver?: Resolver; }; +const dataPropsToExcludeMap: { [key in keyof RaidbossData]: boolean } = { + job: true, + me: true, + role: true, + party: true, + lang: true, + parserLang: true, + displayLang: true, + currentHP: true, + options: true, + inCombat: true, + triggerSetConfig: true, + ShortName: true, + StopCombat: true, + ParseLocaleFloat: true, + CanStun: true, + CanSilence: true, + CanSleep: true, + CanCleanse: true, + CanFeint: true, + CanAddle: true, +}; +const dataPropsToExclude = Object.keys(dataPropsToExcludeMap); + export class Resolver { private promise?: Promise; private run?: ResolverFunc; @@ -153,7 +178,7 @@ export default class PopupTextAnalysis extends StubbedPopupText { continue; const resolver = this.currentResolver = new Resolver({ - initialData: EmulatorCommon.cloneData(this.data), + initialData: EmulatorCommon.cloneData(this.data, dataPropsToExclude), suppressed: false, executed: false, }); @@ -162,7 +187,7 @@ export default class PopupTextAnalysis extends StubbedPopupText { resolver.setFinal(() => { const currentLine = getCurrentLogLine(); - resolver.status.finalData = EmulatorCommon.cloneData(this.data); + resolver.status.finalData = EmulatorCommon.cloneData(this.data, dataPropsToExclude); delete resolver.triggerHelper?.resolver; if (this.callback) this.callback(currentLine, resolver.triggerHelper, resolver.status, this.data); @@ -186,7 +211,7 @@ export default class PopupTextAnalysis extends StubbedPopupText { } if (r !== false) { const resolver = this.currentResolver = new Resolver({ - initialData: EmulatorCommon.cloneData(this.data), + initialData: EmulatorCommon.cloneData(this.data, dataPropsToExclude), suppressed: false, executed: false, }); @@ -198,7 +223,7 @@ export default class PopupTextAnalysis extends StubbedPopupText { resolver.setFinal(() => { const currentLine = getCurrentLogLine(); - resolver.status.finalData = EmulatorCommon.cloneData(this.data); + resolver.status.finalData = EmulatorCommon.cloneData(this.data, dataPropsToExclude); delete resolver.triggerHelper?.resolver; if (this.callback) this.callback(currentLine, resolver.triggerHelper, resolver.status, this.data); From 838c57452917a07a96a286a3d3f58cbd61e258b4 Mon Sep 17 00:00:00 2001 From: Jacob Keller Date: Sun, 4 Jan 2026 01:34:55 -0800 Subject: [PATCH 4/4] raidemulator: do not keep finalData if it is unchanged --- ui/raidboss/emulator/data/PopupTextAnalysis.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ui/raidboss/emulator/data/PopupTextAnalysis.ts b/ui/raidboss/emulator/data/PopupTextAnalysis.ts index 4fd8efd6f3b..f0de7224906 100644 --- a/ui/raidboss/emulator/data/PopupTextAnalysis.ts +++ b/ui/raidboss/emulator/data/PopupTextAnalysis.ts @@ -187,7 +187,11 @@ export default class PopupTextAnalysis extends StubbedPopupText { resolver.setFinal(() => { const currentLine = getCurrentLogLine(); - resolver.status.finalData = EmulatorCommon.cloneData(this.data, dataPropsToExclude); + const finalData = EmulatorCommon.cloneData(this.data, dataPropsToExclude); + if (JSON.stringify(finalData) !== JSON.stringify(resolver.status.initialData)) + resolver.status.finalData = finalData; + else + delete resolver.status.initialData; delete resolver.triggerHelper?.resolver; if (this.callback) this.callback(currentLine, resolver.triggerHelper, resolver.status, this.data); @@ -223,7 +227,11 @@ export default class PopupTextAnalysis extends StubbedPopupText { resolver.setFinal(() => { const currentLine = getCurrentLogLine(); - resolver.status.finalData = EmulatorCommon.cloneData(this.data, dataPropsToExclude); + const finalData = EmulatorCommon.cloneData(this.data, dataPropsToExclude); + if (JSON.stringify(finalData) !== JSON.stringify(resolver.status.initialData)) + resolver.status.finalData = finalData; + else + delete resolver.status.initialData; delete resolver.triggerHelper?.resolver; if (this.callback) this.callback(currentLine, resolver.triggerHelper, resolver.status, this.data);