Skip to content

Commit e1ea205

Browse files
authored
feat: Add bloat analysis UI and improve singleton detection (#18)
1 parent 98582ff commit e1ea205

25 files changed

Lines changed: 631 additions & 148 deletions
148 KB
Loading
-2.63 KB
Loading

x2-data-explorer/docs/user-guide.adoc

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Multiple files can be loaded at the same time. Each file will be in a separate t
1818

1919
Save files are produced when you save a regular game. History files are produced when you save a ladder game. Save and history files have the same format and work the same way, except that save files have an additional header with some extra information. Both file types contain the entire game state history since the last time the history was archived (which occurs during each map transition).
2020

21-
After loading one of these files, the tab will contain three sub-tabs: the <<general-tab>>, the <<frames-tab>>, and the <<problems-tab>>.
21+
After loading one of these files, the tab will contain four sub-tabs: the <<general-tab>>, the <<frames-tab>>, the <<bloat-tab>>, and the <<problems-tab>>.
2222

2323
[#general-tab]
2424
=== General Tab
@@ -29,7 +29,7 @@ For a save file, the General tab contains four sections:
2929

3030
* Top left: information from the save file header. This can tell you things like: when the save file was created, whether it was created in tactical or strategy, and which version of the game created it.
3131
* Top right: a list of mods and DLCs that were active when the save was created.
32-
* Bottom left: a list of singleton XComGameStateObjects. Singleton objects are objects whose class has `bSingletonStateType` set to true. Historical versions of these objects are not serialized, so unlike other objects, there is no way to know what a singleton object looked like at each frame in the history. Fortunately, singleton objects are very rare. XComGameState_Analytics is the only such class in the base game.
32+
* Bottom left: a list of singleton XComGameStateObjects. Singleton objects are objects whose class has `bSingletonStateType` set to true. Historical versions of these objects are not serialized, except in strategy saves, where the archive frame holds the original state of the object. So unlike other objects, there is no way to know what a singleton object looked like at each frame in the history. Fortunately, singleton objects are very rare. XComGameState_Analytics is the only such class in the base game.
3333
* Bottom right: the object properties tree, populated when you click on any singleton object.
3434
3535
For a history file, the General tab is mostly the same, except the list of mods and DLCs is removed, and the header information only contains a few fields that are available from XComGameStateHistory.
@@ -83,6 +83,20 @@ IMPORTANT: When writing expressions to match fields that are Unreal names, remem
8383

8484
The frames and objects tables have a summary column that provides a short description of what that frame or object is about. The summaries are powered by Groovy scripts. Default scripts are included in the application, but can be modified if you like. In the Preferences menu at the top of the screen, click the State Object Summary Script or Context Summary Script menu items to edit the scripts.
8585

86+
[#bloat-tab]
87+
=== Bloat Analysis Tab
88+
89+
image::bloat-analysis-tab.png[]
90+
91+
The Bloat Analysis tab helps you figure out what's using the most space in your save files. It has six sub-tabs:
92+
93+
. *Object Class Stats* provides summary statistics for objects that are subclasses of `XComGameState_BaseObject`. It shows the number of objects, min/max/average number of deltas per object (i.e. the number of times an object was changed), and the min/max/average size of each delta.
94+
. *Context Class Stats* provides summary statistics for objects that are subclasses of `XComGameStateContext`. It shows the number of frames that used a context of each class, and the min/max/average/total bytes used by those contexts.
95+
. *Largest Delta Objects* lists the top 500 largest delta objects in the file. The object ID and frame number are provided so you can switch to the Frames tab to get a better idea of what was happening in that frame.
96+
. *Largest Full Objects* lists the top 500 largest full (non-delta) objects in the file. The object ID and frame number are provided so you can switch to the Frames tab to get a better idea of what was happening in that frame.
97+
. *Largest Contexts* lists the top 500 largest contexts in the file. The frame number is provided so you can switch to the Frames tab to get a better idea of what was happening in that frame.
98+
. *Singletons* shows the size of all singleton state objects in the file.
99+
86100
[#problems-tab]
87101
=== Problems Tab
88102

x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/GameStateContext.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111

1212
import groovy.lang.Script;
1313

14-
public class GameStateContext {
14+
public class GameStateContext implements ISizedObject {
1515

1616
private static final UnrealName INTERRUPTION_HISTORY_INDEX = new UnrealName("InterruptionHistoryIndex");
1717
private static final UnrealName HISTORY_INDEX_INTERRUPTED_BY_SELF = new UnrealName("HistoryIndexInterruptedBySelf");
1818
private static final UnrealName INTERRUPTION_STATUS = new UnrealName("InterruptionStatus");
1919

20+
private final int sizeInFile;
2021
private final UnrealName type;
2122
private final Map<UnrealName, NonVersionedField> fields;
2223
private final HistoryFrame frame;
@@ -26,8 +27,9 @@ public class GameStateContext {
2627
private final HistoryFrame interruptedByThis;
2728
private HistoryFrame resumedBy;
2829

29-
public GameStateContext(GenericObject object, HistoryFrame frame, Map<Integer, HistoryFrame> frames, Script summarizer,
30+
public GameStateContext(int sizeInFile, GenericObject object, HistoryFrame frame, Map<Integer, HistoryFrame> frames, Script summarizer,
3031
List<HistoryFileProblem> problemsDetected) {
32+
this.sizeInFile = sizeInFile;
3133
this.frame = frame;
3234

3335
type = object.type;
@@ -68,6 +70,11 @@ public Object propertyMissing(String name) {
6870
return fields.get(new UnrealName(name));
6971
}
7072

73+
@Override
74+
public int getSizeInFile() {
75+
return sizeInFile;
76+
}
77+
7178
public UnrealName getType() {
7279
return type;
7380
}

x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/GameStateObject.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
import groovy.lang.Script;
1414
import javafx.scene.control.TreeItem;
1515

16-
public class GameStateObject {
16+
public class GameStateObject implements ISizedObject {
1717

1818
private static final UnrealName OBJECT_ID = new UnrealName("ObjectID");
1919
private static final UnrealName REMOVED = new UnrealName("bRemoved");
2020

21+
private final int sizeInFile;
2122
private final int objectId;
2223
private final boolean removed; // note that it is possible for an object to be added and removed in the same state
2324
private final UnrealName type;
@@ -27,8 +28,9 @@ public class GameStateObject {
2728
private final GameStateObject previousVersion;
2829
private GameStateObject nextVersion;
2930

30-
public GameStateObject(Map<Integer, GameStateObject> stateObjects, GenericObject currentVersion, HistoryFrame frame, Script summarizer,
31-
List<HistoryFileProblem> problemsDetected) {
31+
public GameStateObject(int sizeInFile, Map<Integer, GameStateObject> stateObjects, GenericObject currentVersion,
32+
HistoryFrame frame, Script summarizer, List<HistoryFileProblem> problemsDetected) {
33+
this.sizeInFile = sizeInFile;
3234
this.frame = frame;
3335

3436
objectId = (int) currentVersion.properties.get(OBJECT_ID);
@@ -71,6 +73,11 @@ public TreeItem<GameStateObjectFieldTreeNode> getFieldsAsTreeNode(boolean onlyMo
7173
return root;
7274
}
7375

76+
@Override
77+
public int getSizeInFile() {
78+
return sizeInFile;
79+
}
80+
7481
public int getObjectId() {
7582
return objectId;
7683
}

x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/HistoryFileReader.java

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
import java.nio.file.Files;
77
import java.nio.file.StandardOpenOption;
88
import java.util.ArrayList;
9+
import java.util.Comparator;
910
import java.util.HashMap;
1011
import java.util.HashSet;
12+
import java.util.LinkedHashMap;
1113
import java.util.List;
1214
import java.util.Map;
13-
import java.util.Set;
1415
import java.util.function.Consumer;
1516
import java.util.function.DoubleConsumer;
1617

@@ -38,38 +39,65 @@ public HistoryFile read(FileChannel in, DoubleConsumer progressPercentCallback,
3839
reader.decompress(in, decompressedIn);
3940
progressTextCallback.accept("Building index");
4041
try (var historyIndex = reader.buildIndex(decompressedIn)) {
41-
Map<Integer, HistoryFrame> frames = new HashMap<>();
42+
XComGameStateHistory history = historyIndex.mapObject(historyIndex.getEntry(0), null, NullXComObjectReferenceResolver.INSTANCE);
43+
var frameRefs = history.History;
44+
var currentFrameNum = history.NumArchivedFrames + 1;
45+
if (!historyIndex.isCreatedByWOTC()) {
46+
// before WOTC, NumArchivedFrames did not exist and archived frames were represented by -1 in the History array
47+
for (int i = frameRefs.size() - 1; i >= 0; i--) {
48+
if (frameRefs.get(i).index() == -1) {
49+
frameRefs = frameRefs.subList(i + 1, frameRefs.size());
50+
currentFrameNum = i + 2;
51+
break;
52+
}
53+
}
54+
}
55+
56+
// first pass to parse the state and detect singletons
57+
var numFrames = frameRefs.size();
58+
var rawFrames = new XComGameState[numFrames];
59+
var seenObjectIndexes = new HashSet<Integer>();
60+
var detectedSingletonTypes = new HashSet<UnrealName>();
61+
for (int i = 0; i < numFrames; i++) {
62+
progressTextCallback.accept("Parsing history frame " + currentFrameNum++);
63+
XComGameState rawFrame = historyIndex.mapObject(
64+
historyIndex.getEntry(frameRefs.get(i).index()), null, NullXComObjectReferenceResolver.INSTANCE);
65+
rawFrames[i] = rawFrame;
66+
for (var objRef : rawFrame.GameStates) {
67+
if (!seenObjectIndexes.add(objRef.index())) {
68+
// multiple frames pointing to same object index, so class must be a singleton
69+
// note that in strategy saves, two versions of a singleton are written
70+
// the first frame (archive frame) points to one version
71+
// all other frames point to the other version
72+
// this does not happen for tactical saves, where all frames point to a single version
73+
detectedSingletonTypes.add(historyIndex.getEntry(objRef.index()).getType());
74+
}
75+
}
76+
}
77+
78+
// second pass to parse the state objects
79+
Map<Integer, HistoryFrame> frames = new LinkedHashMap<>();
4280
Map<Integer, GenericObject> parsedObjects = new HashMap<>();
4381
Map<Integer, GameStateObject> stateObjects = new HashMap<>();
44-
Set<X2HistoryIndexEntry> singletonStates = new HashSet<>();
82+
Map<X2HistoryIndexEntry, Integer> singletonStates = new HashMap<>();
4583
List<HistoryFileProblem> problemsDetected = new ArrayList<>();
4684
var contextSummarizer = ScriptPreferences.CONTEXT_SUMMARY.getExecutable();
4785
var objectSummarizer = ScriptPreferences.STATE_OBJECT_SUMMARY.getExecutable();
48-
49-
XComGameStateHistory history = historyIndex.mapObject(historyIndex.getEntry(0), null, NullXComObjectReferenceResolver.INSTANCE);
50-
int numFrames = history.History.size();
51-
boolean foundFirstFrame = historyIndex.isCreatedByWOTC();
5286
for (int i = 0; i < numFrames; i++) {
53-
var frameRef = history.History.get(i);
54-
if (!foundFirstFrame && frameRef.index() == -1) {
55-
// before WOTC, NumArchivedFrames did not exist and archived frames were represented by -1 in the History array
56-
continue;
57-
}
58-
XComGameState rawFrame = historyIndex.mapObject(
59-
historyIndex.getEntry(frameRef.index()), null, NullXComObjectReferenceResolver.INSTANCE);
87+
XComGameState rawFrame = rawFrames[i];
6088
var parsedFrame = new HistoryFrame(rawFrame.HistoryIndex, rawFrame.TimeStamp);
61-
progressTextCallback.accept("Parsing history frame " + rawFrame.HistoryIndex);
89+
progressTextCallback.accept("Parsing objects for history frame " + rawFrame.HistoryIndex);
6290

6391
var contextEntry = historyIndex.getEntry(rawFrame.StateChangeContext.index());
6492
var contextVisitor = new GenericObjectVisitor(null);
6593
historyIndex.parseObject(contextEntry, contextVisitor);
6694
var parsedContext = new GameStateContext(
67-
contextVisitor.getRootObject(), parsedFrame, frames, contextSummarizer, problemsDetected);
95+
contextEntry.getLength(), contextVisitor.getRootObject(), parsedFrame, frames, contextSummarizer, problemsDetected);
6896

6997
for (var stateObjectRef : rawFrame.GameStates) {
7098
var stateObjectEntry = historyIndex.getEntry(stateObjectRef.index());
71-
if (stateObjectEntry.isSingletonState()) {
72-
singletonStates.add(stateObjectEntry);
99+
if (detectedSingletonTypes.contains(stateObjectEntry.getType())) {
100+
singletonStates.putIfAbsent(stateObjectEntry, rawFrame.HistoryIndex);
73101
continue;
74102
}
75103

@@ -94,7 +122,7 @@ public HistoryFile read(FileChannel in, DoubleConsumer progressPercentCallback,
94122
stateObject.properties.get(PREV_FRAME_HIST_INDEX))));
95123
} else {
96124
parsedObjects.put(stateObjectRef.index(), stateObject);
97-
new GameStateObject(stateObjects, stateObject, parsedFrame, objectSummarizer, problemsDetected); // adds itself to the map
125+
new GameStateObject(stateObjectEntry.getLength(), stateObjects, stateObject, parsedFrame, objectSummarizer, problemsDetected); // adds itself to the map
98126
}
99127
}
100128

@@ -103,22 +131,27 @@ public HistoryFile read(FileChannel in, DoubleConsumer progressPercentCallback,
103131
progressPercentCallback.accept(((double) i + 1) / numFrames);
104132
}
105133

134+
progressTextCallback.accept("Parsing singletons");
106135
var singletons = singletonStates
136+
.entrySet()
107137
.stream()
108-
.map(s -> {
138+
.map(entry -> {
139+
var key = entry.getKey();
109140
var stateObjectVisitor = new GenericObjectVisitor(null);
110141
try {
111-
historyIndex.parseObject(s, stateObjectVisitor);
142+
historyIndex.parseObject(key, stateObjectVisitor);
112143
} catch (IOException e) {
113144
// should never happen
114145
throw new UncheckedIOException(e);
115146
}
116-
return new HistorySingletonObject(stateObjectVisitor.getRootObject());
147+
return new HistorySingletonObject(key.getLength(), entry.getValue(), stateObjectVisitor.getRootObject());
117148
})
118-
.sorted((a, b) -> a.getType().compareTo(b.getType()))
149+
.sorted(Comparator
150+
.<HistorySingletonObject, UnrealName>comparing(s -> s.getType())
151+
.thenComparingInt(s -> s.getFirstFrame()))
119152
.toList();
120153

121-
return new HistoryFile(history, frames.values().stream().sorted().toList(), singletons, problemsDetected);
154+
return new HistoryFile(history, List.copyOf(frames.values()), singletons, problemsDetected);
122155
}
123156
} finally {
124157
Files.deleteIfExists(decompressedFile);

x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/HistorySingletonObject.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,34 @@
55

66
import com.github.rcd47.x2data.lib.unreal.mappings.UnrealName;
77

8-
public class HistorySingletonObject {
8+
public class HistorySingletonObject implements ISizedObject {
99

1010
private static final UnrealName OBJECT_ID = new UnrealName("ObjectID");
1111

12+
private final int sizeInFile;
13+
private final int firstFrame;
1214
private final int objectId;
1315
private final UnrealName type;
1416
private final Map<UnrealName, NonVersionedField> fields;
1517

16-
public HistorySingletonObject(GenericObject object) {
18+
public HistorySingletonObject(int sizeInFile, int firstFrame, GenericObject object) {
19+
this.sizeInFile = sizeInFile;
20+
this.firstFrame = firstFrame;
1721
objectId = (int) object.properties.get(OBJECT_ID);
1822
type = object.type;
1923
fields = new HashMap<>();
2024
object.properties.forEach((k, v) -> fields.put(k, new NonVersionedField(v)));
2125
}
2226

27+
@Override
28+
public int getSizeInFile() {
29+
return sizeInFile;
30+
}
31+
32+
public int getFirstFrame() {
33+
return firstFrame;
34+
}
35+
2336
public int getObjectId() {
2437
return objectId;
2538
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.github.rcd47.x2data.explorer.file;
2+
3+
public interface ISizedObject {
4+
5+
int getSizeInFile();
6+
7+
}

0 commit comments

Comments
 (0)