Add in-game Tracy capture (#2156)

This commit is contained in:
Jason Mitchell authored and GitHub committed 2026-09-27 14:16:01 -07:00
1 parent 233c74e0d7
commit 74ea5c4709
39 files changed
+1492 -210

No files matched your search

+76 -17
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Builds the libTracyClient shared library for the tracy-client module.
# Builds the libTracyClient and AngelicaTracyCapture shared libraries for the tracy-client module.
set -euo pipefail
TRACY_VERSION="${TRACY_VERSION:-v0.14.0}"
TRACY_VERSION="${TRACY_VERSION:-v0.14.1}"
TRACY_VERSION_BARE="${TRACY_VERSION#v}"
OUT_DIR="${OUT_DIR:-dist/tracy-natives}"
TRACY_REPO="https://github.com/wolfpld/tracy"
@@ -31,36 +31,95 @@ else
git clone --depth 1 --branch "$TRACY_VERSION" "$TRACY_REPO" "$SRC_DIR"
fi
BUILD_DIR="$WORK_DIR/build"
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-11.0}"
cmake -S "$REPO_ROOT/tracy-client/src/main/native" -B "$BUILD_DIR" \
CLIENT_BUILD_DIR="$WORK_DIR/build-client"
cmake -S "$REPO_ROOT/tracy-client/src/main/native" -B "$CLIENT_BUILD_DIR" \
-DTRACY_SRC_DIR="$SRC_DIR" \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=ON \
-DTRACY_STATIC=OFF \
-DTRACY_ENABLE=ON \
-DTRACY_ON_DEMAND=ON \
-DTRACY_DELAYED_INIT=ON \
-DTRACY_MANUAL_LIFETIME=ON \
-DTRACY_NO_SAMPLING=ON \
-DTRACY_NO_SYSTEM_TRACING=ON \
-DTRACY_NO_CRASH_HANDLER=ON \
-DTRACY_LTO=OFF
cmake --build "$BUILD_DIR" --config Release --parallel
cmake --build "$CLIENT_BUILD_DIR" --config Release --parallel
mkdir -p "$OUT_DIR"
CPM_CACHE_DIR="$WORK_DIR/cpm"
CAPTURE_BUILD_DIR="$WORK_DIR/build-capture"
cmake -S "$REPO_ROOT/tracy-client/src/main/native-capture" -B "$CAPTURE_BUILD_DIR" \
-DTRACY_SRC_DIR="$SRC_DIR" \
-DCMAKE_BUILD_TYPE=Release \
-DCPM_SOURCE_CACHE="$CPM_CACHE_DIR"
cmake --build "$CAPTURE_BUILD_DIR" --config Release --parallel
find_one() {
find "$1" -name "$2" 2>/dev/null | head -1
}
CLIENT_LIB_NAME=""
CLIENT_LIB=""
for NAME in libTracyClient.so libTracyClient.dylib TracyClient.dll; do
LIB="$(find "$BUILD_DIR" -name "$NAME" | head -1)"
LIB="$(find_one "$CLIENT_BUILD_DIR" "$NAME")"
if [[ -n "$LIB" ]]; then
cp -L "$LIB" "$OUT_DIR/$NAME"
cp "$SRC_DIR/LICENSE" "$OUT_DIR/LICENSE"
mkdir -p "$RES_DIR"
cp -L "$LIB" "$RES_DIR/$NAME"
cp "$SRC_DIR/LICENSE" "$RES_DIR/LICENSE"
echo "built: $OUT_DIR/$NAME ($TRACY_VERSION), staged: $RES_DIR/$NAME"
exit 0
CLIENT_LIB="$LIB"
CLIENT_LIB_NAME="$NAME"
break
fi
done
echo "error: built library not found under $BUILD_DIR" >&2
exit 1
if [[ -z "$CLIENT_LIB" ]]; then
echo "error: libTracyClient not found under $CLIENT_BUILD_DIR" >&2
exit 1
fi
CAPTURE_LIB_NAME=""
CAPTURE_LIB=""
for NAME in libAngelicaTracyCapture.so libAngelicaTracyCapture.dylib AngelicaTracyCapture.dll; do
LIB="$(find_one "$CAPTURE_BUILD_DIR" "$NAME")"
if [[ -n "$LIB" ]]; then
CAPTURE_LIB="$LIB"
CAPTURE_LIB_NAME="$NAME"
break
fi
done
if [[ -z "$CAPTURE_LIB" ]]; then
echo "error: AngelicaTracyCapture not found under $CAPTURE_BUILD_DIR" >&2
exit 1
fi
STRIPPED_CAPTURE_LIB="$WORK_DIR/$CAPTURE_LIB_NAME"
cp -L "$CAPTURE_LIB" "$STRIPPED_CAPTURE_LIB"
case "$PLAT_OS" in
macos) strip -x "$STRIPPED_CAPTURE_LIB" ;;
linux) strip --strip-unneeded "$STRIPPED_CAPTURE_LIB" ;;
*) ;;
esac
LICENSE_ZSTD="$(find_one "$CPM_CACHE_DIR/zstd" LICENSE)"
LICENSE_CAPSTONE="$(find_one "$CPM_CACHE_DIR/capstone" LICENSE.TXT)"
LICENSE_PPQSORT="$(find_one "$CPM_CACHE_DIR/ppqsort" LICENSE)"
LICENSE_NAMES=(LICENSE LICENSE-zstd LICENSE-capstone LICENSE-ppqsort)
LICENSE_SRCS=("$SRC_DIR/LICENSE" "$LICENSE_ZSTD" "$LICENSE_CAPSTONE" "$LICENSE_PPQSORT")
for i in "${!LICENSE_NAMES[@]}"; do
if [[ -z "${LICENSE_SRCS[$i]}" || ! -f "${LICENSE_SRCS[$i]}" ]]; then
echo "error: ${LICENSE_NAMES[$i]} source not found under $CPM_CACHE_DIR" >&2
exit 1
fi
done
mkdir -p "$OUT_DIR" "$RES_DIR"
for DEST in "$OUT_DIR" "$RES_DIR"; do
cp -L "$CLIENT_LIB" "$DEST/$CLIENT_LIB_NAME"
cp -L "$STRIPPED_CAPTURE_LIB" "$DEST/$CAPTURE_LIB_NAME"
for i in "${!LICENSE_NAMES[@]}"; do
cp "${LICENSE_SRCS[$i]}" "$DEST/${LICENSE_NAMES[$i]}"
done
done
echo "built: $OUT_DIR/$CLIENT_LIB_NAME, $OUT_DIR/$CAPTURE_LIB_NAME ($TRACY_VERSION)"
echo "staged: $RES_DIR/$CLIENT_LIB_NAME, $RES_DIR/$CAPTURE_LIB_NAME"
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- name: Build libTracyClient
- name: Build Tracy natives
shell: bash
env:
OUT_DIR: dist/tracy-natives
@@ -40,10 +40,10 @@ public final class SystemProperties {
public static final boolean SDL_DISABLE_IN_PASS_CLEAR = Boolean.getBoolean("angelica.sdlgpu.disableInPassClear");
// Tracy
public static final boolean TRACY = Boolean.getBoolean("angelica.tracy");
public static final boolean TRACY_FINE_ZONES = Boolean.getBoolean("angelica.tracy.fineZones");
public static final Boolean TRACY_OVERRIDE = parseBooleanOverride("angelica.tracy");
public static final Boolean TRACY_FINE_ZONES_OVERRIDE = parseBooleanOverride("angelica.tracy.fineZones");
public static final String TRACY_DIR = System.getProperty("angelica.tracy.dir", "angelica" + File.separator + "natives" + File.separator + "tracy");
public static final int TRACY_MAX_SRC_LOCS = Math.max(16, Integer.getInteger("angelica.tracy.maxSrcLocs", 4096));
public static final Integer TRACY_MAX_SRC_LOCS_OVERRIDE = Integer.getInteger("angelica.tracy.maxSrcLocs");
// Profiling
public static final String PROFILE_OPTS = System.getProperty("angelica.profile.opts", "event=wall,interval=5ms,alloc=512k,lock=10ms");
@@ -152,6 +152,11 @@ public final class SystemProperties {
}
}
private static Boolean parseBooleanOverride(String key) {
final String raw = System.getProperty(key);
return raw == null ? null : Boolean.parseBoolean(raw);
}
private static double parseDouble(String key) {
final String raw = System.getProperty(key);
if (raw == null || raw.isEmpty()) return 0.0D;
@@ -1,6 +1,5 @@
package com.gtnewhorizons.angelica.glsm.profiling;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.glsm.CaptureGate;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
import com.sun.management.ThreadMXBean;
@@ -66,12 +65,12 @@ public final class Tracy {
static {
TracyBackend backend = null;
if (SystemProperties.TRACY) {
if (TracyOptions.enabled()) {
backend = loadAndInit();
}
BACKEND = backend;
ENABLED = backend != null;
FINE_ZONES = ENABLED && SystemProperties.TRACY_FINE_ZONES;
FINE_ZONES = ENABLED && TracyOptions.fineZones();
if (ENABLED) {
Runtime.getRuntime().addShutdownHook(new Thread(Tracy::shutdown, "Tracy-Shutdown"));
SECTIONS.registerCategory(SECTION_WORLD, "World");
@@ -86,6 +85,18 @@ public final class Tracy {
private static void shutdown() {
SHUTTING_DOWN = true;
if (isCaptureActive(BACKEND.captureState())) {
BACKEND.captureStop();
final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
while (isCaptureActive(BACKEND.captureState()) && System.nanoTime() < deadline) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
final Thread worker = new Thread(BACKEND::shutdown, "Tracy-Shutdown-Worker");
worker.setDaemon(true);
worker.start();
@@ -96,6 +107,10 @@ public final class Tracy {
}
}
private static boolean isCaptureActive(int state) {
return state == TracyBackend.CAPTURE_CONNECTING || state == TracyBackend.CAPTURE_RECORDING || state == TracyBackend.CAPTURE_SAVING;
}
private static TracyBackend loadAndInit() {
TracyBackend found = null;
try {
@@ -104,11 +119,11 @@ public final class Tracy {
found = it.next();
}
} catch (ServiceConfigurationError | LinkageError e) {
LOGGER.warn("Tracy requested (-Dangelica.tracy=true) but backend unavailable: {}", e.getMessage());
LOGGER.warn("Tracy: backend unavailable: {}", e.getMessage());
return null;
}
if (found == null) {
LOGGER.warn("Tracy requested (-Dangelica.tracy=true) but no TracyBackend service present");
LOGGER.warn("Tracy: enabled but the angelica-tracy jar is not installed");
return null;
}
if (!implementsCurrentAbi(found)) return null;
@@ -120,7 +135,7 @@ public final class Tracy {
init.start();
try {
if (!done.await(15, TimeUnit.SECONDS) && result.compareAndSet(null, Boolean.FALSE)) {
LOGGER.warn("Tracy requested (-Dangelica.tracy=true) but init timed out; profiling disabled");
LOGGER.warn("Tracy: init timed out; profiling disabled");
return null;
}
} catch (InterruptedException e) {
@@ -131,7 +146,7 @@ public final class Tracy {
return null;
}
if (!Boolean.TRUE.equals(result.get())) {
LOGGER.warn("Tracy requested (-Dangelica.tracy=true) but native init failed; profiling disabled");
LOGGER.warn("Tracy: native init failed; profiling disabled");
return null;
}
return backend;
@@ -388,6 +403,31 @@ public final class Tracy {
return ENABLED && BACKEND.isConnected();
}
public static String captureStart(String path, int seconds) {
if (!ENABLED) return "Tracy not enabled";
return BACKEND.captureStart(path, seconds);
}
public static void captureStop() {
if (!ENABLED) return;
BACKEND.captureStop();
}
public static int captureState() {
if (!ENABLED) return 0;
return BACKEND.captureState();
}
public static long captureElapsedMs() {
if (!ENABLED) return 0;
return BACKEND.captureElapsedMs();
}
public static String captureError() {
if (!ENABLED) return "";
return BACKEND.captureError();
}
private static boolean gpuInitAttempted;
private static boolean gpuReady;
@Setter private static boolean gpuZonesEnabled = true;
@@ -12,6 +12,13 @@ public interface TracyBackend {
int SEVERITY_ERROR = 4;
int SEVERITY_FATAL = 5;
int CAPTURE_IDLE = 0;
int CAPTURE_CONNECTING = 1;
int CAPTURE_RECORDING = 2;
int CAPTURE_SAVING = 3;
int CAPTURE_DONE = 4;
int CAPTURE_FAILED = 5;
boolean init();
long internSrcLoc(String name, int color);
@@ -47,4 +54,12 @@ public interface TracyBackend {
boolean gpuBeginZone(long srcLoc);
void gpuEndZone();
void gpuCollect();
default String captureStart(String path, int seconds) {
return "Tracy capture unsupported by this angelica-tracy build";
}
default void captureStop() {}
default int captureState() { return CAPTURE_IDLE; }
default long captureElapsedMs() { return 0; }
default String captureError() { return ""; }
}
@@ -0,0 +1,64 @@
package com.gtnewhorizons.angelica.glsm.profiling;
import com.gtnewhorizons.angelica.config.SystemProperties;
public final class TracyOptions {
private static boolean configEnabled;
private static boolean configRemote;
private static boolean configFineZones;
private static int configMaxSrcLocs = 4096;
private TracyOptions() {}
public static void latch(boolean enabled, boolean allowRemote, boolean fineZones, int maxSrcLocs) {
configEnabled = enabled;
configRemote = allowRemote;
configFineZones = fineZones;
configMaxSrcLocs = maxSrcLocs;
}
public static boolean enabled() {
return resolveBoolean(SystemProperties.TRACY_OVERRIDE, configEnabled);
}
public static boolean allowRemote() {
return configRemote;
}
public static boolean fineZones() {
return resolveBoolean(SystemProperties.TRACY_FINE_ZONES_OVERRIDE, configFineZones);
}
public static int maxSrcLocs() {
return resolveMaxSrcLocs(SystemProperties.TRACY_MAX_SRC_LOCS_OVERRIDE, configMaxSrcLocs);
}
public static boolean configEnabled() {
return configEnabled;
}
public static boolean configRemote() {
return configRemote;
}
public static boolean configFineZones() {
return configFineZones;
}
public static int configMaxSrcLocs() {
return configMaxSrcLocs;
}
public static boolean backendPresent() {
return TracyOptions.class.getResource("/com/gtnewhorizons/angelica/tracy/TracyClientBackend.class") != null;
}
static boolean resolveBoolean(Boolean override, boolean config) {
return override != null ? override : config;
}
static int resolveMaxSrcLocs(Integer override, int config) {
return Math.max(16, override != null ? override : config);
}
}
@@ -0,0 +1,62 @@
package com.gtnewhorizons.angelica.glsm.profiling;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TracyOptionsTest {
@Test
void resolveBooleanFallsBackToConfigWhenNoOverride() {
assertTrue(TracyOptions.resolveBoolean(null, true));
assertFalse(TracyOptions.resolveBoolean(null, false));
}
@Test
void resolveBooleanOverrideWinsEitherWay() {
assertTrue(TracyOptions.resolveBoolean(Boolean.TRUE, false));
assertFalse(TracyOptions.resolveBoolean(Boolean.FALSE, true));
}
@Test
void resolveMaxSrcLocsFallsBackToConfigWhenNoOverride() {
assertEquals(4096, TracyOptions.resolveMaxSrcLocs(null, 4096));
}
@Test
void resolveMaxSrcLocsOverrideWins() {
assertEquals(8192, TracyOptions.resolveMaxSrcLocs(8192, 4096));
}
@Test
void resolveMaxSrcLocsClampsBothConfigAndOverrideToTheMinimum() {
assertEquals(16, TracyOptions.resolveMaxSrcLocs(null, 4));
assertEquals(16, TracyOptions.resolveMaxSrcLocs(1, 4096));
}
@Test
void latchExposesConfigValuesThroughTheAccessors() {
TracyOptions.latch(true, true, true, 8192);
assertTrue(TracyOptions.configEnabled());
assertTrue(TracyOptions.configRemote());
assertTrue(TracyOptions.configFineZones());
assertEquals(8192, TracyOptions.configMaxSrcLocs());
TracyOptions.latch(false, false, false, 4096);
assertFalse(TracyOptions.configEnabled());
assertFalse(TracyOptions.configRemote());
assertFalse(TracyOptions.configFineZones());
assertEquals(4096, TracyOptions.configMaxSrcLocs());
}
@Test
void allowRemoteHasNoJvmOverrideAndTracksConfigDirectly() {
TracyOptions.latch(false, true, false, 4096);
assertTrue(TracyOptions.allowRemote());
TracyOptions.latch(false, false, false, 4096);
assertFalse(TracyOptions.allowRemote());
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ eventbus = "7.0-beta.15"
glslTransformLib = "0.2.0-33.g3ec381e"
antlr4Runtime = "4.13.2"
jcpp = "1.4.14"
tracy = "0.14.0"
tracy = "0.14.1"
asyncProfiler = "4.4"
retroFuturaBootstrap = "1.1.0"
+3
View File
@@ -104,6 +104,9 @@ if [ -n "$JFR_OUT" ]; then
PROFILE_ARGS+=(-Dangelica.profile.opts="$ASPROF_OPTS")
fi
fi
if [ "$MODE" = "tracy" ]; then
PROFILE_ARGS+=(-Dangelica.tracy=true)
fi
if [ "$MODE" = "tracy" ]; then
if ! command -v "$TRACY_CAPTURE" >/dev/null 2>&1 && [ ! -x "$TRACY_CAPTURE" ]; then
@@ -1,7 +1,6 @@
package com.gtnewhorizons.angelica;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.glsm.profiling.Tracy;
import com.gtnewhorizons.angelica.proxy.CommonProxy;
import cpw.mods.fml.common.Mod;
import cpw.mods.fml.common.SidedProxy;
@@ -32,7 +31,7 @@ public class AngelicaMod {
public static final String MOD_ID = "angelica";
public static final Logger LOGGER = LogManager.getLogger("Angelica");
public static final boolean lwjglDebug = SystemProperties.LWJGL_DEBUG;
public static final boolean tracyEnabled = Tracy.ENABLED;
public static final String KEY_CATEGORY = "key.categories.angelica";
@SidedProxy(
clientSide = "com.gtnewhorizons.angelica.proxy.ClientProxy",
@@ -0,0 +1,110 @@
package com.gtnewhorizons.angelica.client.gui;
import com.google.common.collect.ImmutableList;
import com.gtnewhorizons.angelica.config.AngelicaConfig;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.debug.profiling.TracyCaptureNotifier;
import com.gtnewhorizons.angelica.glsm.profiling.Tracy;
import com.gtnewhorizons.angelica.glsm.profiling.TracyBackend;
import com.gtnewhorizons.angelica.glsm.profiling.TracyOptions;
import me.jellysquid.mods.sodium.client.gui.options.ActionOption;
import me.jellysquid.mods.sodium.client.gui.options.OptionFlag;
import me.jellysquid.mods.sodium.client.gui.options.OptionGroup;
import me.jellysquid.mods.sodium.client.gui.options.OptionImpl;
import me.jellysquid.mods.sodium.client.gui.options.OptionPage;
import me.jellysquid.mods.sodium.client.gui.options.control.ControlValueFormatter;
import me.jellysquid.mods.sodium.client.gui.options.control.SliderControl;
import me.jellysquid.mods.sodium.client.gui.options.control.TickBoxControl;
import me.jellysquid.mods.sodium.client.gui.options.storage.AngelicaOptionsStorage;
import net.minecraft.client.resources.I18n;
import java.util.function.Supplier;
public class TracyOptionPages {
private static final AngelicaOptionsStorage angelicaOpts = new AngelicaOptionsStorage();
public static OptionPage tracy() {
final String stopLabel = I18n.format("options.angelica.tracy.captureAction.stop");
final String savingLabel = I18n.format("options.angelica.tracy.captureAction.saving");
final String startLabel = I18n.format("options.angelica.tracy.captureAction.start");
final Supplier<String> captureActionLabel = () -> switch (Tracy.captureState()) {
case TracyBackend.CAPTURE_CONNECTING, TracyBackend.CAPTURE_RECORDING -> stopLabel;
case TracyBackend.CAPTURE_SAVING -> savingLabel;
default -> startLabel;
};
final String restartLabel = I18n.format("options.angelica.tracy.status.restart");
final String runningLabel = I18n.format("options.angelica.tracy.status.running");
final String runningForcedLabel = I18n.format("options.angelica.tracy.status.runningForced");
final String failedLabel = I18n.format("options.angelica.tracy.status.failed");
final String offLabel = I18n.format("options.angelica.tracy.status.off");
final Supplier<String> statusLabel = () -> {
if (AngelicaConfig.enableTracy != TracyOptions.configEnabled()
|| AngelicaConfig.tracyAllowRemote != TracyOptions.configRemote()
|| AngelicaConfig.tracyFineZones != TracyOptions.configFineZones()
|| AngelicaConfig.tracyMaxSrcLocs != TracyOptions.configMaxSrcLocs()) {
return restartLabel;
}
if (Tracy.ENABLED) {
return SystemProperties.TRACY_OVERRIDE != null ? runningForcedLabel : runningLabel;
}
return TracyOptions.enabled() ? failedLabel : offLabel;
};
return new OptionPage(I18n.format("options.angelica.tracy.page"), ImmutableList.of(
OptionGroup.createBuilder()
.add(OptionImpl.createBuilder(boolean.class, angelicaOpts)
.setName(I18n.format("options.angelica.tracy.enable"))
.setTooltip(overrideTooltip("options.angelica.tracy.enable.tooltip", "angelica.tracy", SystemProperties.TRACY_OVERRIDE != null))
.setControl(TickBoxControl::new)
.setBinding((opts, value) -> AngelicaConfig.enableTracy = value, opts -> AngelicaConfig.enableTracy)
.setEnabled(SystemProperties.TRACY_OVERRIDE == null)
.setFlags(OptionFlag.REQUIRES_GAME_RESTART)
.build())
.add(OptionImpl.createBuilder(boolean.class, angelicaOpts)
.setName(I18n.format("options.angelica.tracy.allowRemote"))
.setTooltip(I18n.format("options.angelica.tracy.allowRemote.tooltip"))
.setControl(TickBoxControl::new)
.setBinding((opts, value) -> AngelicaConfig.tracyAllowRemote = value, opts -> AngelicaConfig.tracyAllowRemote)
.setFlags(OptionFlag.REQUIRES_GAME_RESTART)
.build())
.add(OptionImpl.createBuilder(boolean.class, angelicaOpts)
.setName(I18n.format("options.angelica.tracy.fineZones"))
.setTooltip(overrideTooltip("options.angelica.tracy.fineZones.tooltip", "angelica.tracy.fineZones", SystemProperties.TRACY_FINE_ZONES_OVERRIDE != null))
.setControl(TickBoxControl::new)
.setBinding((opts, value) -> AngelicaConfig.tracyFineZones = value, opts -> AngelicaConfig.tracyFineZones)
.setEnabled(SystemProperties.TRACY_FINE_ZONES_OVERRIDE == null)
.setFlags(OptionFlag.REQUIRES_GAME_RESTART)
.build())
.add(OptionImpl.createBuilder(int.class, angelicaOpts)
.setName(I18n.format("options.angelica.tracy.maxSrcLocs"))
.setTooltip(overrideTooltip("options.angelica.tracy.maxSrcLocs.tooltip", "angelica.tracy.maxSrcLocs", SystemProperties.TRACY_MAX_SRC_LOCS_OVERRIDE != null))
.setControl(o -> new SliderControl(o, 1024, 16384, 1024, ControlValueFormatter.number()))
.setBinding((opts, value) -> AngelicaConfig.tracyMaxSrcLocs = value, opts -> AngelicaConfig.tracyMaxSrcLocs)
.setEnabled(SystemProperties.TRACY_MAX_SRC_LOCS_OVERRIDE == null)
.setFlags(OptionFlag.REQUIRES_GAME_RESTART)
.build())
.build(),
OptionGroup.createBuilder()
.add(new ActionOption(I18n.format("options.angelica.tracy.status"), I18n.format("options.angelica.tracy.status.tooltip"),
statusLabel, () -> {}, () -> false))
.build(),
OptionGroup.createBuilder()
.add(OptionImpl.createBuilder(int.class, angelicaOpts)
.setName(I18n.format("options.angelica.tracy.captureLength"))
.setTooltip(I18n.format("options.angelica.tracy.captureLength.tooltip"))
.setControl(o -> new SliderControl(o, 10, 300, 10, ControlValueFormatter.seconds()))
.setBinding((opts, value) -> AngelicaConfig.tracyCaptureSeconds = value, opts -> AngelicaConfig.tracyCaptureSeconds)
.setEnabled(Tracy.ENABLED)
.build())
.add(new ActionOption(I18n.format("options.angelica.tracy.captureAction"), I18n.format("options.angelica.tracy.captureAction.tooltip"),
captureActionLabel, TracyCaptureNotifier.INSTANCE::toggle, () -> Tracy.ENABLED))
.build()));
}
private static String overrideTooltip(String baseKey, String flag, boolean overridden) {
final String base = I18n.format(baseKey);
return overridden ? base + " " + I18n.format("options.angelica.tracy.overriddenBy", flag) : base;
}
}
@@ -2,11 +2,15 @@ package com.gtnewhorizons.angelica.commands;
// Debug commands adapted from Beddium by Ven and FalsePattern
import com.gtnewhorizons.angelica.config.AngelicaConfig;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.debug.ChunkDebugMinimap;
import com.gtnewhorizons.angelica.debug.flyby.FlybyRoute;
import com.gtnewhorizons.angelica.debug.flyby.FlybyRunner;
import com.gtnewhorizons.angelica.debug.profiling.AsprofRecorder;
import com.gtnewhorizons.angelica.debug.profiling.TracyCaptureNotifier;
import com.gtnewhorizons.angelica.glsm.profiling.Tracy;
import com.gtnewhorizons.angelica.glsm.profiling.TracyBackend;
import com.gtnewhorizons.angelica.rendering.RenderRecovery;
import com.gtnewhorizons.angelica.rendering.celeritas.CeleritasDebugScreenHandler;
import com.gtnewhorizons.angelica.rendering.celeritas.CeleritasWorldRenderer;
@@ -19,23 +23,42 @@ import net.minecraft.util.ChatComponentText;
import net.minecraft.util.EnumChatFormatting;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class AngelicaCommand extends CommandBase {
private static final List<String> SUBCOMMANDS = buildSubcommands();
private static final Map<String, String> HELP = buildHelp();
private static final String[] SUBCOMMANDS = buildSubcommands();
private static final String USAGE = "/angelica <" + String.join("|", SUBCOMMANDS) + ">";
private static List<String> buildSubcommands() {
final List<String> subcommands = new ArrayList<>(Arrays.asList("wireframe", "fog", "minimap", "flyby", "profile", "help"));
if (isDeobfuscatedEnvironment()) {
subcommands.add("crashtest");
private static Map<String, String> buildHelp() {
final Map<String, String> help = new LinkedHashMap<>();
if (SystemProperties.debugTooling()) {
help.put("wireframe", helpLine("wireframe", "Toggle wireframe rendering"));
help.put("fog", helpLine("fog", "Toggle fog debug on F3"));
help.put("minimap", helpLine("minimap", "Toggle chunk debug overlay"));
help.put("flyby", helpLine("flyby <" + FlybyRoute.ids() + ">", "Run a deterministic benchmark route"));
help.put("profile", helpLine("profile <start|stop|status>", "Control async-profiler (JFR) recording"));
if (SystemProperties.isDeobf()) {
help.put("crashtest", helpLine("crashtest", "Arm a crash on the next tile entity render"));
}
}
if (Tracy.ENABLED) {
help.put("tracy", helpLine("tracy <start|stop|status>", "Control Tracy capture recording"));
}
return help;
}
private static String[] buildSubcommands() {
final String[] subcommands = HELP.keySet().toArray(new String[HELP.size() + 1]);
subcommands[HELP.size()] = "help";
return subcommands;
}
private static boolean isDeobfuscatedEnvironment() {
return SystemProperties.isDeobf();
private static String helpLine(String syntax, String description) {
return EnumChatFormatting.GRAY + " /angelica " + syntax + EnumChatFormatting.WHITE + " - " + description;
}
@Override
@@ -45,7 +68,7 @@ public class AngelicaCommand extends CommandBase {
@Override
public String getCommandUsage(ICommandSender sender) {
return "/angelica <wireframe|fog|minimap|flyby|profile" + (isDeobfuscatedEnvironment() ? "|crashtest" : "") + "|help>";
return USAGE;
}
@Override
@@ -73,7 +96,7 @@ public class AngelicaCommand extends CommandBase {
@Override
public List<String> addTabCompletionOptions(ICommandSender sender, String[] args) {
if (args.length == 1) {
return getListOfStringsMatchingLastWord(args, SUBCOMMANDS.toArray(new String[0]));
return getListOfStringsMatchingLastWord(args, SUBCOMMANDS);
}
if (args.length == 2 && "flyby".equalsIgnoreCase(args[0])) {
final List<String> options = new ArrayList<>();
@@ -84,7 +107,10 @@ public class AngelicaCommand extends CommandBase {
return getListOfStringsMatchingLastWord(args, options.toArray(new String[0]));
}
if (args.length == 2 && "profile".equalsIgnoreCase(args[0])) {
return getListOfStringsMatchingLastWord(args, new String[] { "start", "stop", "status" });
return getListOfStringsMatchingLastWord(args, "start", "stop", "status");
}
if (args.length == 2 && "tracy".equalsIgnoreCase(args[0])) {
return getListOfStringsMatchingLastWord(args, "start", "stop", "status");
}
return new ArrayList<>();
}
@@ -97,6 +123,10 @@ public class AngelicaCommand extends CommandBase {
}
final String subcommand = args[0].toLowerCase();
if (!HELP.containsKey(subcommand)) {
sendHelp(sender);
return;
}
if (requiresCheats(subcommand) && !cheatsAllowed()) {
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.RED + "[Angelica] " + subcommand + " needs cheats enabled on a world you host"));
return;
@@ -108,14 +138,8 @@ public class AngelicaCommand extends CommandBase {
case "minimap" -> handleMinimap(sender);
case "flyby" -> handleFlyby(sender, args);
case "profile" -> handleProfile(sender, args);
case "crashtest" -> {
if (isDeobfuscatedEnvironment()) {
handleCrashTest(sender);
} else {
sendHelp(sender);
}
}
default -> sendHelp(sender);
case "tracy" -> handleTracy(sender, args);
case "crashtest" -> handleCrashTest(sender);
}
}
@@ -239,15 +263,75 @@ public class AngelicaCommand extends CommandBase {
}
}
private void handleTracy(ICommandSender sender, String[] args) {
if (args.length < 2) {
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.AQUA + "[Angelica] " + EnumChatFormatting.WHITE + "Usage: /angelica tracy <start|stop|status> [seconds]"));
return;
}
switch (args[1].toLowerCase()) {
case "start" -> {
int seconds = AngelicaConfig.tracyCaptureSeconds;
if (args.length > 2) {
try {
seconds = Integer.parseInt(args[2]);
} catch (NumberFormatException e) {
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.AQUA + "[Angelica] " + EnumChatFormatting.WHITE + "Usage: /angelica tracy start [seconds]"));
return;
}
if (seconds < 0) {
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.AQUA + "[Angelica] " + EnumChatFormatting.WHITE + "Usage: /angelica tracy start [seconds]"));
return;
}
}
final String error = TracyCaptureNotifier.INSTANCE.startCapture(seconds);
if (error != null) {
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.RED + "[Angelica] " + error));
} else {
final String length = seconds > 0 ? seconds + "s" : "until stopped";
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.AQUA + "[Angelica] " + EnumChatFormatting.WHITE + "Tracy capture started (" + length + "): " + TracyCaptureNotifier.INSTANCE.path()));
}
}
case "stop" -> {
final int state = Tracy.captureState();
if (state == TracyBackend.CAPTURE_CONNECTING || state == TracyBackend.CAPTURE_RECORDING) {
Tracy.captureStop();
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.AQUA + "[Angelica] " + EnumChatFormatting.WHITE + "Stopping Tracy capture, saving..."));
} else if (state == TracyBackend.CAPTURE_SAVING) {
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.RED + "[Angelica] Tracy capture is already saving"));
} else {
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.RED + "[Angelica] No Tracy capture is running"));
}
}
case "status" -> {
final int state = Tracy.captureState();
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.AQUA + "[Angelica] " + EnumChatFormatting.WHITE + "Tracy capture: " + tracyStateName(state) + " (" + Tracy.captureElapsedMs() / 1000L + "s)"));
if (state == TracyBackend.CAPTURE_FAILED) {
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.GRAY + " " + Tracy.captureError()));
} else if (TracyCaptureNotifier.INSTANCE.path() != null) {
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.GRAY + " " + TracyCaptureNotifier.INSTANCE.path()));
}
}
default -> sender.addChatMessage(new ChatComponentText(EnumChatFormatting.AQUA + "[Angelica] " + EnumChatFormatting.WHITE + "Usage: /angelica tracy <start|stop|status> [seconds]"));
}
}
private static String tracyStateName(int state) {
return switch (state) {
case TracyBackend.CAPTURE_IDLE -> "idle";
case TracyBackend.CAPTURE_CONNECTING -> "connecting";
case TracyBackend.CAPTURE_RECORDING -> "recording";
case TracyBackend.CAPTURE_SAVING -> "saving";
case TracyBackend.CAPTURE_DONE -> "done";
case TracyBackend.CAPTURE_FAILED -> "failed";
default -> "unknown";
};
}
private void sendHelp(ICommandSender sender) {
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.AQUA + "[Angelica] Debug Commands:"));
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.GRAY + " /angelica wireframe" + EnumChatFormatting.WHITE + " - Toggle wireframe rendering"));
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.GRAY + " /angelica fog" + EnumChatFormatting.WHITE + " - Toggle fog debug on F3"));
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.GRAY + " /angelica minimap" + EnumChatFormatting.WHITE + " - Toggle chunk debug overlay"));
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.GRAY + " /angelica flyby <" + FlybyRoute.ids() + ">" + EnumChatFormatting.WHITE + " - Run a deterministic benchmark route"));
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.GRAY + " /angelica profile <start|stop|status>" + EnumChatFormatting.WHITE + " - Control async-profiler (JFR) recording"));
if (isDeobfuscatedEnvironment()) {
sender.addChatMessage(new ChatComponentText(EnumChatFormatting.GRAY + " /angelica crashtest" + EnumChatFormatting.WHITE + " - Arm a crash on the next tile entity render"));
for (String line : HELP.values()) {
sender.addChatMessage(new ChatComponentText(line));
}
}
@@ -472,6 +472,32 @@ public class AngelicaConfig {
"minecraft:andesite", "minecraft:dirt", "minecraft:granite", "minecraft:grass", "minecraft:mycelium", "minecraft:sand", "minecraft:soul_sand", "etfuturum:calcite", "etfuturum:coarse_dirt", "etfuturum:concrete_powder", "etfuturum:grass_path", "BiomesOPlenty:ash", "BiomesOPlenty:driedDirt", "BiomesOPlenty:hardDirt", "BiomesOPlenty:hardSand", "BiomesOPlenty:mud", "BiomesOPlenty:newBopDirt", "Botania:dirtPath", "Botania:enchantedSoil", "Botania:livingrock", "Botania:prismarine", "Botania:shimmerrock", "Botany:loam", "Botany:loamNoWeed", "Botany:soil", "Botany:soilNoWeed", "chisel:moss", "chisel:moss_carpet", "ExtraUtilities:color_hellsand", "ExtraUtilities:cursedearthside", "GalaxySpace:acentauribbgrunt", "GalaxySpace:acentauribbsubgrunt", "GalaxySpace:barnardaCdirt", "GalaxySpace:barnardaEgrunt", "GalaxySpace:barnardaEsubgrunt", "GalaxySpace:barnardaFgrunt", "GalaxySpace:barnardaFsubgrunt", "GalaxySpace:callistoblocks", "GalaxySpace:ceresblocks", "GalaxySpace:deimosblocks", "GalaxySpace:europagrunt", "GalaxySpace:ganymedeblocks", "GalaxySpace:haumeablocks", "GalaxySpace:ioblocks", "GalaxySpace:makemakegrunt", "GalaxySpace:mercuryblocks", "GalaxySpace:mirandablocks", "GalaxySpace:oberonblocks", "GalaxySpace:phobosblocks", "GalaxySpace:proteusblocks", "GalaxySpace:tcetieblocks", "GalaxySpace:titanblocks", "GalaxySpace:tritonblocks", "GalaxySpace:vegabgrunt", "GalaxySpace:vegabsubgrunt", "GalaxySpace:venusblocks", "gregtech:gt.blockgranites", "IC2:blockBasalt", "MagicBees:magicbees.enchantedEarth", "RandomThings:fertilizedDirt", "ToxicEverglades:blockDarkWorldGround2", "VillageNames:concretePowder", "witchery:pitdirt"
};
@Config.Comment("Enable the Tracy profiler backend. Requires angelica-tracy.jar on the classpath. Overridden by -Dangelica.tracy")
@Config.DefaultBoolean(false)
@Config.RequiresMcRestart
public static boolean enableTracy;
@Config.Comment("Allow Tracy to accept connections from other machines, not just localhost")
@Config.DefaultBoolean(false)
@Config.RequiresMcRestart
public static boolean tracyAllowRemote;
@Config.Comment("Emit Tracy zones for many more call sites. More detail, more overhead. Overridden by -Dangelica.tracy.fineZones")
@Config.DefaultBoolean(false)
@Config.RequiresMcRestart
public static boolean tracyFineZones;
@Config.Comment("Max distinct Tracy zone/message call sites to preallocate. Overridden by -Dangelica.tracy.maxSrcLocs")
@Config.DefaultInt(4096)
@Config.RangeInt(min = 1024, max = 16384)
@Config.RequiresMcRestart
public static int tracyMaxSrcLocs;
@Config.Comment("Default length in seconds for a Tracy capture started from the command or video settings. 0 = until stopped")
@Config.DefaultInt(60)
@Config.RangeInt(min = 0, max = 3600)
public static int tracyCaptureSeconds;
public static void applyGpuCullingMode() {
GpuCulling.setMode(gpuCullingMode == null ? GpuCullingMode.CPU_ONLY : gpuCullingMode);
}
@@ -40,7 +40,7 @@ public final class AsprofRecorder {
if (!available()) return "async-profiler is not available on this platform/build";
if (SESSION.recordingId() != 0) return "already recording to " + SESSION.outputPath();
File file = SystemProperties.PROFILE_OUTPUT.isEmpty() ? new File(SystemProperties.PROFILE_DIR, fileName(tag, System.currentTimeMillis())) : new File(SystemProperties.PROFILE_OUTPUT);
File file = SystemProperties.PROFILE_OUTPUT.isEmpty() ? new File(SystemProperties.PROFILE_DIR, fileName(tag, "jfr", System.currentTimeMillis())) : new File(SystemProperties.PROFILE_OUTPUT);
file = file.getAbsoluteFile();
if (file.exists()) return "output file already exists: " + file.getPath();
final File parent = file.getParentFile();
@@ -121,9 +121,9 @@ public final class AsprofRecorder {
return opts.isEmpty() ? "start,jfr,file=" + file : "start,jfr," + opts + ",file=" + file;
}
static String fileName(String tag, long nowMillis) {
static String fileName(String tag, String ext, long nowMillis) {
final SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT);
return "angelica-" + tag + "-" + format.format(new Date(nowMillis)) + ".jfr";
return "angelica-" + tag + "-" + format.format(new Date(nowMillis)) + "." + ext;
}
static String firstLine(String s) {
@@ -0,0 +1,102 @@
package com.gtnewhorizons.angelica.debug.profiling;
import com.gtnewhorizons.angelica.AngelicaMod;
import com.gtnewhorizons.angelica.config.AngelicaConfig;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.glsm.profiling.Tracy;
import com.gtnewhorizons.angelica.glsm.profiling.TracyBackend;
import cpw.mods.fml.client.registry.ClientRegistry;
import cpw.mods.fml.common.FMLCommonHandler;
import cpw.mods.fml.common.eventhandler.SubscribeEvent;
import cpw.mods.fml.common.gameevent.TickEvent;
import net.minecraft.client.Minecraft;
import net.minecraft.client.settings.KeyBinding;
import net.minecraft.util.ChatComponentText;
import net.minecraft.util.EnumChatFormatting;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.File;
import java.util.Locale;
public final class TracyCaptureNotifier {
private static final Logger LOGGER = LogManager.getLogger("Angelica/Tracy");
public static final TracyCaptureNotifier INSTANCE = new TracyCaptureNotifier();
private File file;
private boolean inFlight;
private KeyBinding captureKey;
private TracyCaptureNotifier() {}
public void register() {
this.captureKey = new KeyBinding("angelica.keybind.tracyCapture", 0, AngelicaMod.KEY_CATEGORY);
ClientRegistry.registerKeyBinding(this.captureKey);
FMLCommonHandler.instance().bus().register(this);
}
public void toggle() {
final int state = Tracy.captureState();
if (state == TracyBackend.CAPTURE_CONNECTING || state == TracyBackend.CAPTURE_RECORDING) {
Tracy.captureStop();
} else if (state != TracyBackend.CAPTURE_SAVING) {
final Minecraft mc = Minecraft.getMinecraft();
final String error = this.startCapture(AngelicaConfig.tracyCaptureSeconds);
if (error != null) {
report(mc, true, "Tracy capture failed to start: " + error);
} else {
report(mc, false, "Tracy capture started: " + this.path());
}
}
}
public String startCapture(int seconds) {
final File candidate = new File(SystemProperties.PROFILE_DIR, AsprofRecorder.fileName("tracy", "tracy", System.currentTimeMillis()));
final String error = Tracy.captureStart(candidate.getPath(), seconds);
if (error != null) return error;
this.file = candidate;
this.inFlight = true;
return null;
}
public String path() {
return this.file == null ? null : this.file.getAbsolutePath();
}
@SubscribeEvent
public void onClientTick(TickEvent.ClientTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
if (this.captureKey != null && this.captureKey.isPressed()) {
this.toggle();
}
if (!this.inFlight) return;
final Minecraft mc = Minecraft.getMinecraft();
final int state = Tracy.captureState();
if (state == TracyBackend.CAPTURE_DONE) {
report(mc, false, "Tracy capture saved: " + this.path() + sizeSuffix(this.file));
this.inFlight = false;
} else if (state == TracyBackend.CAPTURE_FAILED) {
report(mc, true, "Tracy capture failed: " + Tracy.captureError());
this.inFlight = false;
}
}
private static String sizeSuffix(File file) {
if (file == null) return "";
final long bytes = file.length();
return String.format(Locale.ROOT, " (%.1f MB)", bytes / (1024.0 * 1024.0));
}
private static void report(Minecraft mc, boolean failure, String text) {
if (mc.thePlayer != null) {
mc.thePlayer.addChatMessage(new ChatComponentText((failure ? EnumChatFormatting.RED : EnumChatFormatting.AQUA) + "[Angelica] " + text));
} else if (failure) {
LOGGER.warn(text);
} else {
LOGGER.info(text);
}
}
}
@@ -11,6 +11,7 @@ import com.gtnewhorizons.angelica.config.FontConfig;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.glsm.loading.DependencyVerifier;
import com.gtnewhorizons.angelica.glsm.loading.Lwjgl3ifyExclusions;
import com.gtnewhorizons.angelica.glsm.profiling.TracyOptions;
import com.gtnewhorizons.angelica.loading.fml.compat.CompatHandlers;
import com.gtnewhorizons.angelica.lwjgl3.MissingDependencySdl;
import com.gtnewhorizons.angelica.sdlgpu.SDLGPUGate;
@@ -66,6 +67,7 @@ public final class AngelicaClientTweaker implements IFMLLoadingPlugin, IEarlyMix
try {
// Angelica Config
ConfigurationManager.registerConfig(AngelicaConfig.class);
TracyOptions.latch(AngelicaConfig.enableTracy, AngelicaConfig.tracyAllowRemote, AngelicaConfig.tracyFineZones, AngelicaConfig.tracyMaxSrcLocs);
ConfigurationManager.registerConfig(CompatConfig.class);
ConfigurationManager.registerConfig(FontConfig.class);
MCPatcherForgeConfig.registerConfig();
@@ -2,12 +2,12 @@ package com.gtnewhorizons.angelica.mixins;
import com.gtnewhorizon.gtnhmixins.builders.IMixins;
import com.gtnewhorizon.gtnhmixins.builders.MixinBuilder;
import com.gtnewhorizons.angelica.AngelicaMod;
import com.gtnewhorizons.angelica.api.BlockLightProvider;
import com.gtnewhorizons.angelica.config.AngelicaConfig;
import com.gtnewhorizons.angelica.config.CompatConfig;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.glsm.CaptureGate;
import com.gtnewhorizons.angelica.glsm.profiling.TracyOptions;
import com.gtnewhorizons.angelica.sdlgpu.SDLGPUGate;
import jss.notfine.config.MCPatcherForgeConfig;
import jss.notfine.config.NotFineConfig;
@@ -236,7 +236,7 @@ public enum Mixins implements IMixins {
ANGELICA_TRACY(new MixinBuilder("Tracy profiler zones from vanilla Profiler sections")
.setPhase(Phase.EARLY)
.setApplyIf(() -> AngelicaMod.tracyEnabled)
.setApplyIf(() -> TracyOptions.enabled() && TracyOptions.backendPresent())
.addCommonMixins(
"angelica.tracy.MixinProfiler_Tracy"
, "angelica.tracy.MixinNetHandlerPlayServer_Tracy"
@@ -250,14 +250,14 @@ public enum Mixins implements IMixins {
ANGELICA_ENABLE_DEBUG(new MixinBuilder()
.setPhase(Phase.EARLY)
.setApplyIf(() -> AngelicaMod.lwjglDebug)
.setApplyIf(() -> SystemProperties.LWJGL_DEBUG)
.addClientMixins(
"angelica.debug.MixinSplashProgress"
)
),
ANGELICA_DEBUG_MARKERS(new MixinBuilder("RenderDoc/RGP debug groups + object labels")
.setPhase(Phase.EARLY)
.setApplyIf(() -> AngelicaMod.lwjglDebug || CaptureGate.enabledAtStartup())
.setApplyIf(() -> SystemProperties.LWJGL_DEBUG || CaptureGate.enabledAtStartup())
.addClientMixins(
"angelica.debug.MixinProfiler"
, "angelica.debug.MixinTextureManager"
@@ -327,7 +327,7 @@ public enum Mixins implements IMixins {
// Not compatible with the lwjgl debug callbacks, so disable if that's enabled
ARCHAIC_SPLASH(new MixinBuilder()
.setPhase(Phase.EARLY)
.setApplyIf(() -> AngelicaConfig.showSplashMemoryBar && !AngelicaMod.lwjglDebug)
.setApplyIf(() -> AngelicaConfig.showSplashMemoryBar && !SystemProperties.LWJGL_DEBUG)
.addClientMixins(
"angelica.archaic.MixinSplashProgress$3",
"angelica.archaic.AccessorSplashProgress"
@@ -52,6 +52,7 @@ import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.debug.F3Direction;
import com.gtnewhorizons.angelica.debug.flyby.FlybyFallGuard;
import com.gtnewhorizons.angelica.debug.flyby.FlybyRunner;
import com.gtnewhorizons.angelica.debug.profiling.TracyCaptureNotifier;
import com.gtnewhorizons.angelica.debug.FrametimeGraph;
import com.gtnewhorizons.angelica.debug.TPSGraph;
import com.gtnewhorizons.angelica.dynamiclights.DynamicLights;
@@ -197,13 +198,17 @@ public final class ClientProxy extends CommonProxy {
}
// Debug tooling
if (SystemProperties.debugTooling()) {
if (SystemProperties.debugTooling() || Tracy.ENABLED) {
ClientCommandHandler.instance.registerCommand(new AngelicaCommand());
}
if (SystemProperties.debugTooling()) {
FMLCommonHandler.instance().bus().register(FlybyRunner.INSTANCE);
MinecraftForge.EVENT_BUS.register(FlybyFallGuard.INSTANCE);
FlybyRunner.INSTANCE.startFromProperties();
}
if (Tracy.ENABLED) {
TracyCaptureNotifier.INSTANCE.register();
}
}
@Override
@@ -42,8 +42,8 @@ public final class FpsReducer {
private FpsReducer() {}
public static void init() {
forceKey = new KeyBinding("angelica.keybind.fps_reducer_force", 0, "key.categories.misc");
disableKey = new KeyBinding("angelica.keybind.fps_reducer_disable", 0, "key.categories.misc");
forceKey = new KeyBinding("angelica.keybind.fps_reducer_force", 0, AngelicaMod.KEY_CATEGORY);
disableKey = new KeyBinding("angelica.keybind.fps_reducer_disable", 0, AngelicaMod.KEY_CATEGORY);
ClientRegistry.registerKeyBinding(forceKey);
ClientRegistry.registerKeyBinding(disableKey);
FMLCommonHandler.instance().bus().register(INSTANCE);
@@ -1,5 +1,6 @@
package com.gtnewhorizons.angelica.zoom;
import com.gtnewhorizons.angelica.AngelicaMod;
import cpw.mods.fml.client.registry.ClientRegistry;
import cpw.mods.fml.common.FMLCommonHandler;
import cpw.mods.fml.common.eventhandler.SubscribeEvent;
@@ -29,7 +30,7 @@ public class Zoom {
private static long zoomLerpLastNano = 0L;
@Getter
private static final KeyBinding zoomKey = new KeyBinding("angelica.keybind.zoom", 0, "key.categories.misc");
private static final KeyBinding zoomKey = new KeyBinding("angelica.keybind.zoom", 0, AngelicaMod.KEY_CATEGORY);
private static boolean zoomEnabled = false;
@@ -3,9 +3,12 @@ package me.jellysquid.mods.sodium.client.gui;
import com.google.common.collect.ImmutableList;
import com.gtnewhorizons.angelica.client.gui.DynamicLightsOptionPages;
import com.gtnewhorizons.angelica.client.gui.ScrollableGuiScreen;
import com.gtnewhorizons.angelica.client.gui.TracyOptionPages;
import com.gtnewhorizons.angelica.compat.mojang.Drawable;
import com.gtnewhorizons.angelica.compat.mojang.Element;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.dynamiclights.DynamicLights;
import com.gtnewhorizons.angelica.glsm.profiling.TracyOptions;
import jss.notfine.gui.GuiCustomMenu;
import me.jellysquid.mods.sodium.client.gui.options.Option;
import me.jellysquid.mods.sodium.client.gui.options.OptionFlag;
@@ -70,6 +73,10 @@ public class SodiumOptionsGUI extends ScrollableGuiScreen {
this.pages.add(DynamicLightsOptionPages.dynamicLights());
}
if (SystemProperties.debugTooling() || TracyOptions.backendPresent()) {
this.pages.add(TracyOptionPages.tracy());
}
if (Iris.enabled) {
shaderPacks = new OptionPage(I18n.format("options.iris.shaderPackSelection"), ImmutableList.of());
this.pages.add(shaderPacks);
@@ -0,0 +1,33 @@
package me.jellysquid.mods.sodium.client.gui.options;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
public class ActionOption extends ButtonOption {
private final Supplier<String> label;
private final Runnable action;
private final BooleanSupplier available;
public ActionOption(String name, String tooltip, Supplier<String> label, Runnable action, BooleanSupplier available) {
super(name, tooltip, 100);
this.label = label;
this.action = action;
this.available = available;
}
@Override
protected String label() {
return this.label.get();
}
@Override
protected void invoke() {
this.action.run();
}
@Override
public boolean isAvailable() {
return this.available.getAsBoolean();
}
}
@@ -0,0 +1,142 @@
package me.jellysquid.mods.sodium.client.gui.options;
import java.util.Collection;
import java.util.Collections;
import me.jellysquid.mods.sodium.client.gui.options.control.Control;
import me.jellysquid.mods.sodium.client.gui.options.control.ControlElement;
import me.jellysquid.mods.sodium.client.gui.options.control.element.ControlElementFactory;
import me.jellysquid.mods.sodium.client.gui.options.control.element.SodiumControlElement;
import me.jellysquid.mods.sodium.client.gui.options.storage.OptionStorage;
import me.jellysquid.mods.sodium.client.util.Dim2i;
public abstract class ButtonOption implements Option<Void> {
private static final int LABEL_HOVERED = 0xFF94E4D3;
private static final int LABEL_IDLE = 0xFFAAAAAA;
private static final OptionStorage<Void> NO_STORAGE = new OptionStorage<Void>() {
@Override
public Void getData() {
return null;
}
@Override
public void save() {}
};
private final String name;
private final String tooltip;
private final Control<Void> control;
protected ButtonOption(String name, String tooltip, int maxWidth) {
this.name = name;
this.tooltip = tooltip;
this.control = new ButtonControl(this, maxWidth);
}
protected abstract String label();
protected abstract void invoke();
@Override
public String getName() {
return this.name;
}
@Override
public String getTooltip() {
return this.tooltip;
}
@Override
public OptionImpact getImpact() {
return null;
}
@Override
public Control<Void> getControl() {
return this.control;
}
@Override
public Void getValue() {
return null;
}
@Override
public void setValue(Void value) {}
@Override
public void reset() {}
@Override
public OptionStorage<?> getStorage() {
return NO_STORAGE;
}
@Override
public boolean isAvailable() {
return true;
}
@Override
public boolean hasChanged() {
return false;
}
@Override
public void applyChanges() {}
@Override
public Collection<OptionFlag> getFlags() {
return Collections.emptySet();
}
private record ButtonControl(ButtonOption target, int maxWidth) implements Control<Void> {
@Override
public Option<Void> getOption() {
return this.target;
}
@Override
public ControlElement<Void> createElement(Dim2i dim, ControlElementFactory factory) {
return new ButtonElement(this.target, dim);
}
@Override
public int getMaxWidth() {
return this.maxWidth;
}
}
private static class ButtonElement extends SodiumControlElement<Void> {
private final ButtonOption target;
ButtonElement(ButtonOption target, Dim2i dim) {
super(target, dim);
this.target = target;
}
@Override
public void render(int mouseX, int mouseY, float delta) {
super.render(mouseX, mouseY, delta);
final String label = this.target.label();
final int width = this.font.getStringWidth(label);
this.drawString(label, this.dim.getLimitX() - width - 6, this.dim.getCenterY() - 4, this.hovered ? LABEL_HOVERED : LABEL_IDLE);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (!this.target.isAvailable() || button != 0 || !this.dim.containsCursor(mouseX, mouseY)) return false;
this.playClickSound();
this.target.invoke();
return true;
}
}
}
@@ -1,149 +1,30 @@
package me.jellysquid.mods.sodium.client.gui.options;
import java.util.Collection;
import java.util.Collections;
import java.util.function.Function;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiScreen;
import me.jellysquid.mods.sodium.client.gui.options.control.Control;
import me.jellysquid.mods.sodium.client.gui.options.control.ControlElement;
import me.jellysquid.mods.sodium.client.gui.options.control.element.ControlElementFactory;
import me.jellysquid.mods.sodium.client.gui.options.control.element.SodiumControlElement;
import me.jellysquid.mods.sodium.client.gui.options.storage.OptionStorage;
import me.jellysquid.mods.sodium.client.util.Dim2i;
/** "Option" that opens a sub screen */
public class SubScreenOption implements Option<Void> {
public class SubScreenOption extends ButtonOption {
private static final int ARROW_HOVERED = 0xFF94E4D3;
private static final int ARROW_IDLE = 0xFFAAAAAA;
private static final String ARROW = ">";
private static final OptionStorage<Void> NO_STORAGE = new OptionStorage<Void>() {
@Override
public Void getData() {
return null;
}
@Override
public void save() {}
};
private final String name;
private final String tooltip;
private final Function<GuiScreen, GuiScreen> destination;
private final Control<Void> control = new SubScreenControl(this);
public SubScreenOption(String name, String tooltip, Function<GuiScreen, GuiScreen> destination) {
this.name = name;
this.tooltip = tooltip;
super(name, tooltip, 30);
this.destination = destination;
}
private void open() {
@Override
protected String label() {
return ARROW;
}
@Override
protected void invoke() {
final var mc = Minecraft.getMinecraft();
mc.displayGuiScreen(this.destination.apply(mc.currentScreen));
}
@Override
public String getName() {
return this.name;
}
@Override
public String getTooltip() {
return this.tooltip;
}
@Override
public OptionImpact getImpact() {
return null;
}
@Override
public Control<Void> getControl() {
return this.control;
}
@Override
public Void getValue() {
return null;
}
@Override
public void setValue(Void value) {}
@Override
public void reset() {}
@Override
public OptionStorage<?> getStorage() {
return NO_STORAGE;
}
@Override
public boolean isAvailable() {
return true;
}
@Override
public boolean hasChanged() {
return false;
}
@Override
public void applyChanges() {}
@Override
public Collection<OptionFlag> getFlags() {
return Collections.emptySet();
}
private record SubScreenControl(SubScreenOption target) implements Control<Void> {
@Override
public Option<Void> getOption() {
return this.target;
}
@Override
public ControlElement<Void> createElement(Dim2i dim, ControlElementFactory factory) {
return new SubScreenElement(this.target, dim);
}
@Override
public int getMaxWidth() {
return 30;
}
}
private static class SubScreenElement extends SodiumControlElement<Void> {
private final SubScreenOption target;
SubScreenElement(SubScreenOption option, Dim2i dim) {
super(option, dim);
this.target = option;
}
@Override
public void render(int mouseX, int mouseY, float delta) {
super.render(mouseX, mouseY, delta);
final var width = this.font.getStringWidth(ARROW);
this.drawString(ARROW, this.dim.getLimitX() - width - 6, this.dim.getCenterY() - 4, this.hovered ? ARROW_HOVERED : ARROW_IDLE);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button != 0 || !this.dim.containsCursor(mouseX, mouseY)) return false;
this.playClickSound();
this.target.open();
return true;
}
}
}
@@ -39,6 +39,10 @@ public interface ControlValueFormatter {
return (v) -> v + "x";
}
static ControlValueFormatter seconds() {
return (v) -> v + "s";
}
static ControlValueFormatter quantity(String name) {
return (v) -> I18n.format(name, v);
}
@@ -155,9 +155,11 @@ key.category.iris.keybinds=Iris Keybinds
iris.keybind.reload=Reload Shaders
iris.keybind.toggleShaders=Toggle Shaders
iris.keybind.shaderPackSelection=Shaderpack Selection Screen
key.categories.angelica=Angelica
angelica.keybind.zoom=Zoom
angelica.keybind.fps_reducer_force=Force FPS Reducer
angelica.keybind.fps_reducer_disable=Toggle FPS Reducer
angelica.keybind.tracyCapture=Start/Stop Tracy Capture
angelica.fps_reducer.hud.forced=FPS reducer: forced
angelica.fps_reducer.hud.disabled=FPS reducer: disabled
@@ -289,6 +291,31 @@ options.angelica.gpuCullingMode.tooltip=Selects the chunk culling pipeline. CPU
options.angelica.gpuCullingMode.cpu_only=CPU Only
options.angelica.gpuCullingMode.compute=Compute
options.angelica.tracy.page=Tracy Profiler
options.angelica.tracy.enable=Enable Tracy
options.angelica.tracy.enable.tooltip=Enable the Tracy profiler backend. Requires angelica-tracy.jar on the classpath. Takes effect after a game restart.
options.angelica.tracy.allowRemote=Allow Remote Connections
options.angelica.tracy.allowRemote.tooltip=Accept Tracy connections from other machines instead of localhost only. Takes effect after a game restart.
options.angelica.tracy.fineZones=Fine Zones
options.angelica.tracy.fineZones.tooltip=Emit Tracy zones for many more call sites. More detail, more overhead. Takes effect after a game restart.
options.angelica.tracy.maxSrcLocs=Max Source Locations
options.angelica.tracy.maxSrcLocs.tooltip=Maximum distinct Tracy zone and message call sites to preallocate. Takes effect after a game restart.
options.angelica.tracy.overriddenBy=Overridden by -D%s
options.angelica.tracy.status=Status
options.angelica.tracy.status.tooltip=Whether Tracy is currently running and whether it matches the settings above.
options.angelica.tracy.status.restart=Restart to apply
options.angelica.tracy.status.running=Running
options.angelica.tracy.status.runningForced=Running (forced by JVM flag)
options.angelica.tracy.status.failed=Failed to start, see log
options.angelica.tracy.status.off=Off
options.angelica.tracy.captureLength=Capture Length
options.angelica.tracy.captureLength.tooltip=Default length of a Tracy capture started from this page or /angelica tracy start. Captures are saved to angelica/profiles and open with Tracy Profiler 0.14.
options.angelica.tracy.captureAction=Capture
options.angelica.tracy.captureAction.tooltip=Starts or stops a Tracy capture. Saved to angelica/profiles and opened with Tracy Profiler 0.14.
options.angelica.tracy.captureAction.start=Start capture
options.angelica.tracy.captureAction.stop=Stop capture
options.angelica.tracy.captureAction.saving=Saving...
pack.iris.select.title=Select
pack.iris.configure.title=Configure
pack.iris.list.label=+ Drag and Drop Shader Packs to add
@@ -0,0 +1,40 @@
package com.gtnewhorizons.angelica.arch;
import com.tngtech.archunit.core.domain.JavaAccess;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class EarlyLoadingBoundaryTest {
private static final String TRACY = "com.gtnewhorizons.angelica.glsm.profiling.Tracy";
private static final Pattern GUARDED = Pattern.compile(
".*/com/gtnewhorizons/angelica/(loading/[^/]+|mixins/Mixins(\\$[^/]*)?|AngelicaMod(\\$[^/]*)?|config/AngelicaConfig(\\$[^/]*)?)\\.class$");
private static final JavaClasses classes = new ClassFileImporter()
.withImportOption(location -> location.matches(GUARDED))
.importPaths("build/classes/java/main");
@Test
void earlyLoadingClassesDoNotAccessTracy() {
assertFalse(classes.isEmpty(), "no guarded classes imported");
final List<String> violations = new ArrayList<>();
for (JavaClass origin : classes) {
for (JavaAccess<?> access : origin.getAccessesFromSelf()) {
if (access.getTargetOwner().getName().equals(TRACY)) {
violations.add(origin.getFullName() + " -> " + access.getTarget().getFullName());
}
}
}
assertTrue(violations.isEmpty(), "Coremod-phase code must not touch glsm.profiling.Tracy:\n" + String.join("\n", violations));
}
}
@@ -39,8 +39,8 @@ class AsprofRecorderTest {
@Test
void fileNameMatchesShape() {
final long fixedMillis = 1_700_000_000_000L;
final String circuit = AsprofRecorder.fileName("circuit", fixedMillis);
final String manual = AsprofRecorder.fileName("manual", fixedMillis);
final String circuit = AsprofRecorder.fileName("circuit", "jfr", fixedMillis);
final String manual = AsprofRecorder.fileName("manual", "jfr", fixedMillis);
assertTrue(circuit.matches("angelica-circuit-\\d{8}-\\d{6}\\.jfr"));
assertTrue(manual.matches("angelica-manual-\\d{8}-\\d{6}\\.jfr"));
}
@@ -48,7 +48,7 @@ class AsprofRecorderTest {
@Test
void fileNameIsDeterministicForSameMillis() {
final long fixedMillis = 1_700_000_000_000L;
assertEquals(AsprofRecorder.fileName("circuit", fixedMillis), AsprofRecorder.fileName("circuit", fixedMillis));
assertEquals(AsprofRecorder.fileName("circuit", "jfr", fixedMillis), AsprofRecorder.fileName("circuit", "jfr", fixedMillis));
}
@Test
@@ -0,0 +1,93 @@
package com.gtnewhorizons.angelica.tracy;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.lwjgl.system.JNI;
import org.lwjgl.system.MemoryStack;
import org.lwjgl.system.MemoryUtil;
import org.lwjgl.system.SharedLibrary;
import java.io.File;
import java.nio.ByteBuffer;
import static org.lwjgl.system.MemoryStack.stackPush;
import static org.lwjgl.system.MemoryUtil.NULL;
final class TracyCapture {
private static final Logger LOGGER = LogManager.getLogger("Tracy");
private static final int MEM_PERCENT = 25;
private static final String UNAVAILABLE = "capture library unavailable on this platform";
private boolean loaded;
private String loadError;
private long capStart;
private long capStop;
private long capState;
private long capElapsedMs;
private long capError;
synchronized String start(String path, int port, int seconds) {
final String err = ensureLoaded();
if (err != null) return err;
final File file = new File(path);
final File parent = file.getParentFile();
if (parent != null && !parent.mkdirs() && !parent.isDirectory()) {
return "cannot create directory " + parent;
}
try (MemoryStack stack = stackPush()) {
final ByteBuffer pathUtf8 = stack.UTF8(path, true);
final int result = JNI.invokePI(MemoryUtil.memAddress(pathUtf8), port, seconds, MEM_PERCENT, capStart);
if (result == 1) return "a capture is already running";
if (result != 0) return "capture start failed (code " + result + ")";
return null;
}
}
synchronized void stop() {
if (!loaded) return;
JNI.invokeV(capStop);
}
synchronized int state() {
return loaded ? JNI.invokeI(capState) : 0;
}
synchronized long elapsedMs() {
return loaded ? JNI.invokeJ(capElapsedMs) : 0L;
}
synchronized String error() {
if (!loaded) return "";
final long ptr = JNI.invokeP(capError);
return ptr == NULL ? "" : MemoryUtil.memUTF8(ptr);
}
private String ensureLoaded() {
if (loaded) return null;
if (loadError != null) return loadError;
final SharedLibrary lib = TracyNativeLoader.load("AngelicaTracyCapture");
if (lib == null) {
loadError = UNAVAILABLE;
return loadError;
}
try {
capStart = req(lib, "ang_cap_start");
capStop = req(lib, "ang_cap_stop");
capState = req(lib, "ang_cap_state");
capElapsedMs = req(lib, "ang_cap_elapsed_ms");
capError = req(lib, "ang_cap_error");
} catch (IllegalStateException e) {
LOGGER.warn("Tracy: {}", e.getMessage());
loadError = UNAVAILABLE;
return loadError;
}
loaded = true;
return null;
}
private static long req(SharedLibrary lib, String name) {
final long address = lib.getFunctionAddress(name);
if (address == NULL) throw new IllegalStateException("missing symbol " + name + " in " + lib.getPath());
return address;
}
}
@@ -1,9 +1,9 @@
package com.gtnewhorizons.angelica.tracy;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.glsm.backend.BackendManager;
import com.gtnewhorizons.angelica.glsm.backend.RenderBackend;
import com.gtnewhorizons.angelica.glsm.profiling.TracyBackend;
import com.gtnewhorizons.angelica.glsm.profiling.TracyOptions;
import com.gtnewhorizons.angelica.glsm.profiling.ZoneStack;
import me.eigenraven.lwjgl3ify.api.Lwjgl3Aware;
import org.apache.logging.log4j.LogManager;
@@ -41,6 +41,8 @@ public final class TracyClientBackend implements TracyBackend {
private SrcLocInterner interner;
private long emptyString;
private int ctxSize;
private int port;
private final TracyCapture capture = new TracyCapture();
int zoneCtxSize() {
return ctxSize;
}
@@ -52,7 +54,7 @@ public final class TracyClientBackend implements TracyBackend {
LOGGER.warn("Tracy: 64-bit JVM required");
return false;
}
lib = TracyNativeLoader.load();
lib = TracyNativeLoader.load("TracyClient");
if (lib == null) return false;
sym = TracyLibrary.resolve(lib);
if (sym == null) return false;
@@ -66,7 +68,27 @@ public final class TracyClientBackend implements TracyBackend {
emptyString = nmemAllocChecked(1);
memPutByte(emptyString, (byte) 0);
interner = new SrcLocInterner(this::allocSrcLoc, SystemProperties.TRACY_MAX_SRC_LOCS);
interner = new SrcLocInterner(this::allocSrcLoc, TracyOptions.maxSrcLocs());
final String envPort = System.getenv("TRACY_PORT");
if (envPort != null) {
port = TracyPorts.parsePort(envPort);
if (port <= 0) {
LOGGER.warn("Tracy: TRACY_PORT='{}' is not a valid port (1-65535); in-game capture disabled", envPort);
}
} else {
port = TracyPorts.pick();
if (port > 0) {
if (!setEnv("TRACY_PORT", String.valueOf(port))) port = 0;
} else {
LOGGER.warn("Tracy: no free port in 8086-8105; in-game capture disabled, set the TRACY_PORT environment variable to pin one");
}
}
if (!TracyOptions.allowRemote() && !(setEnv("TRACY_ONLY_LOCALHOST", "1") && setEnv("TRACY_ONLY_IPV4", "1"))) {
LOGGER.warn("Tracy: cannot restrict the listener to localhost; not starting (enable Allow remote connections on the Tracy settings page to allow remote)");
return false;
}
LOGGER.info("Tracy: using port {} ({})", port, TracyOptions.allowRemote() ? "remote allowed" : "localhost only");
JNI.invokeV(sym.startupProfiler);
if (JNI.invokeI(sym.profilerStarted) == 0) {
@@ -82,6 +104,14 @@ public final class TracyClientBackend implements TracyBackend {
}
}
private boolean setEnv(String name, String value) {
try (MemoryStack stack = stackPush()) {
final ByteBuffer nameUtf8 = stack.UTF8(name, true);
final ByteBuffer valueUtf8 = stack.UTF8(value, true);
return JNI.invokePPI(memAddress(nameUtf8), memAddress(valueUtf8), sym.setEnv) == 0;
}
}
private long allocSrcLoc(String name, int color) {
final ByteBuffer nameUtf8 = MemoryUtil.memUTF8(name);
final long srcLoc = nmemAllocChecked(32);
@@ -242,6 +272,32 @@ public final class TracyClientBackend implements TracyBackend {
return JNI.invokeI(sym.connected) != 0;
}
@Override
public String captureStart(String path, int seconds) {
if (port <= 0) return "no free Tracy port in 8086-8105; set the TRACY_PORT environment variable";
return capture.start(path, port, seconds);
}
@Override
public void captureStop() {
capture.stop();
}
@Override
public int captureState() {
return capture.state();
}
@Override
public long captureElapsedMs() {
return capture.elapsedMs();
}
@Override
public String captureError() {
return capture.error();
}
@Override
public void shutdown() {
JNI.invokeV(sym.shutdownProfiler);
@@ -36,6 +36,7 @@ final class TracyLibrary {
final long gpuTimeSync;
final long gpuNewContext;
final long gpuContextName;
final long setEnv;
static TracyLibrary resolve(SharedLibrary lib) {
try {
@@ -72,6 +73,7 @@ final class TracyLibrary {
gpuTimeSync = req(lib, "___tracy_emit_gpu_time_sync");
gpuNewContext = req(lib, "___tracy_emit_gpu_new_context");
gpuContextName = req(lib, "___tracy_emit_gpu_context_name");
setEnv = req(lib, "ang_set_env");
}
private static long req(SharedLibrary lib, String name) {
@@ -33,7 +33,7 @@ final class TracyNativeLoader {
private TracyNativeLoader() {}
static SharedLibrary load() {
static SharedLibrary load(String baseName) {
final OS os = OS.detect(System.getProperty("os.name", "").toLowerCase());
final String arch = System.getProperty("os.arch", "").toLowerCase();
final boolean arm64 = arch.contains("aarch64") || arch.contains("arm64");
@@ -43,9 +43,9 @@ final class TracyNativeLoader {
}
final String platform = os.name().toLowerCase() + (arm64 ? "-arm64" : "-x64");
final String libName = switch (os) {
case WINDOWS -> "TracyClient.dll";
case MACOS -> "libTracyClient.dylib";
case LINUX -> "libTracyClient.so";
case WINDOWS -> baseName + ".dll";
case MACOS -> "lib" + baseName + ".dylib";
case LINUX -> "lib" + baseName + ".so";
};
final String resource = "/natives/tracy/" + TRACY_VERSION + "/" + platform + "/" + libName;
@@ -63,11 +63,11 @@ final class TracyNativeLoader {
final String ext = libName.substring(libName.lastIndexOf('.') + 1);
final File dir = new File(SystemProperties.TRACY_DIR);
final File target = new File(dir, "libTracyClient-" + TRACY_VERSION + "-" + platform + "." + ext);
final File target = new File(dir, "lib" + baseName + "-" + TRACY_VERSION + "-" + platform + "." + ext);
try {
if (!target.isFile() || target.length() != lib.length || !Arrays.equals(Files.readAllBytes(target.toPath()), lib)) {
if (!dir.isDirectory() && !dir.mkdirs()) throw new IOException("cannot create " + dir);
final File tmp = File.createTempFile("libTracyClient", "." + ext, dir);
final File tmp = File.createTempFile("lib" + baseName, "." + ext, dir);
Files.write(tmp.toPath(), lib);
Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
@@ -0,0 +1,29 @@
package com.gtnewhorizons.angelica.tracy;
import java.io.IOException;
import java.net.ServerSocket;
final class TracyPorts {
private static final int RANGE_START = 8086;
private static final int RANGE_END = 8105;
private TracyPorts() {}
static int pick() {
for (int port = RANGE_START; port <= RANGE_END; port++) {
try (ServerSocket socket = new ServerSocket(port)) {
return port;
} catch (IOException ignored) {}
}
return 0;
}
static int parsePort(String value) {
try {
final int port = Integer.parseInt(value.trim());
return port >= 1 && port <= 65535 ? port : 0;
} catch (NumberFormatException e) {
return 0;
}
}
}
@@ -0,0 +1,42 @@
cmake_minimum_required(VERSION 3.16)
# 13.3: TracyServer uses floating-point std::to_chars, unavailable in older libc++
set(CMAKE_OSX_DEPLOYMENT_TARGET 13.3)
project(angelica-tracy-capture LANGUAGES C CXX)
if(NOT DEFINED TRACY_SRC_DIR)
message(FATAL_ERROR "TRACY_SRC_DIR must be set to a Tracy source checkout")
endif()
set(NO_ISA_EXTENSIONS ON)
set(NO_LTO ON)
set(NO_STATISTICS ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_C_VISIBILITY_PRESET hidden)
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)
include(${TRACY_SRC_DIR}/cmake/config.cmake)
include(${TRACY_SRC_DIR}/cmake/vendor.cmake)
include(${TRACY_SRC_DIR}/cmake/server.cmake)
add_library(AngelicaTracyCapture SHARED angelica_tracy_capture.cpp)
set_target_properties(AngelicaTracyCapture PROPERTIES
OUTPUT_NAME "AngelicaTracyCapture"
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN ON
POSITION_INDEPENDENT_CODE ON
)
target_link_libraries(AngelicaTracyCapture PRIVATE TracyServer)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_link_options(AngelicaTracyCapture PRIVATE -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL)
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
target_link_options(AngelicaTracyCapture PRIVATE
-Wl,-exported_symbols_list,${CMAKE_CURRENT_SOURCE_DIR}/angelica_tracy_capture.exports
)
endif()
@@ -0,0 +1,208 @@
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cinttypes>
#include <cstdint>
#include <cstdio>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#if defined(_WIN32)
#include <windows.h>
#else
#include <unistd.h>
#endif
#include "TracyFileWrite.hpp"
#include "TracySysUtil.hpp"
#include "TracyWorker.hpp"
#if defined(_WIN32)
#define ANG_CAP_API __declspec(dllexport)
#else
#define ANG_CAP_API __attribute__((visibility("default")))
#endif
namespace {
enum CapState : int {
STATE_IDLE = 0,
STATE_CONNECTING = 1,
STATE_RECORDING = 2,
STATE_SAVING = 3,
STATE_DONE = 4,
STATE_FAILED = 5,
};
std::atomic<int> g_state{ STATE_IDLE };
std::atomic<bool> g_stopRequested{ false };
std::atomic<int64_t> g_recordStartNs{ 0 };
std::atomic<int64_t> g_frozenElapsedMs{ -1 };
std::mutex g_mutex;
std::thread g_thread;
std::string g_error;
std::string g_errorSnapshot;
struct ThreadJoiner {
~ThreadJoiner() {
g_stopRequested.store(true, std::memory_order_release);
if (g_thread.joinable()) {
g_thread.join();
}
}
};
ThreadJoiner g_threadJoiner;
uint64_t CurrentPid() {
#if defined(_WIN32)
return (uint64_t)GetCurrentProcessId();
#else
return (uint64_t)getpid();
#endif
}
void SetError(const std::string& msg) {
std::lock_guard<std::mutex> lock(g_mutex);
g_error = msg;
}
void RunCapture(std::string path, int port, int seconds, int memPercent) {
try {
int64_t memLimit = -1;
if (memPercent > 0) {
const int64_t clamped = std::clamp<int64_t>(memPercent, 1, 999);
memLimit = clamped * (int64_t)tracy::GetPhysicalMemorySize() / 100;
}
tracy::Worker worker("127.0.0.1", (uint16_t)port, memLimit);
const auto waitStart = std::chrono::steady_clock::now();
while (!worker.HasData()) {
if (g_stopRequested.load(std::memory_order_acquire)) {
SetError("capture cancelled before the client connected");
g_state.store(STATE_FAILED, std::memory_order_release);
return;
}
const auto handshake = worker.GetHandshakeStatus();
if (handshake == tracy::HandshakeProtocolMismatch) {
SetError("client uses an incompatible protocol version");
g_state.store(STATE_FAILED, std::memory_order_release);
return;
}
if (handshake == tracy::HandshakeNotAvailable) {
SetError("client not available for connection");
g_state.store(STATE_FAILED, std::memory_order_release);
return;
}
if (handshake == tracy::HandshakeDropped) {
SetError("client disconnected during handshake");
g_state.store(STATE_FAILED, std::memory_order_release);
return;
}
if (std::chrono::steady_clock::now() - waitStart >= std::chrono::seconds(5)) {
SetError("timed out connecting to the Tracy client; its port may be taken, set -Dangelica.tracy.port");
g_state.store(STATE_FAILED, std::memory_order_release);
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
const uint64_t pid = worker.GetPid();
if (pid != CurrentPid()) {
char buf[96];
snprintf(buf, sizeof(buf), "attached to another process (pid %" PRIu64 ")", pid);
SetError(buf);
worker.Disconnect();
g_state.store(STATE_FAILED, std::memory_order_release);
return;
}
g_state.store(STATE_RECORDING, std::memory_order_release);
const auto t0 = std::chrono::steady_clock::now();
g_recordStartNs.store(t0.time_since_epoch().count(), std::memory_order_release);
while (worker.IsConnected()) {
const auto now = std::chrono::steady_clock::now();
const bool timeUp = seconds > 0 && std::chrono::duration_cast<std::chrono::seconds>(now - t0).count() >= seconds;
if (g_stopRequested.load(std::memory_order_acquire) || timeUp) {
worker.Disconnect();
while (worker.IsConnected()) std::this_thread::sleep_for(std::chrono::milliseconds(100));
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - t0).count();
g_frozenElapsedMs.store(elapsedMs, std::memory_order_release);
g_state.store(STATE_SAVING, std::memory_order_release);
std::unique_ptr<tracy::FileWrite> f(tracy::FileWrite::Open(path.c_str(), tracy::FileCompression::Zstd, 3, 4));
if (!f) {
SetError("cannot open output file");
g_state.store(STATE_FAILED, std::memory_order_release);
return;
}
worker.Write(*f, false);
f->Finish();
g_state.store(STATE_DONE, std::memory_order_release);
} catch (const std::exception& e) {
SetError(e.what());
g_state.store(STATE_FAILED, std::memory_order_release);
} catch (...) {
SetError("unknown capture error");
g_state.store(STATE_FAILED, std::memory_order_release);
}
}
}
extern "C" {
ANG_CAP_API int ang_cap_start(const char* path, int port, int seconds, int memPercent) {
std::lock_guard<std::mutex> lock(g_mutex);
const int state = g_state.load(std::memory_order_acquire);
if (state == STATE_CONNECTING || state == STATE_RECORDING || state == STATE_SAVING) {
return 1;
}
if (g_thread.joinable()) {
g_thread.join();
}
g_stopRequested.store(false, std::memory_order_release);
g_recordStartNs.store(0, std::memory_order_release);
g_frozenElapsedMs.store(-1, std::memory_order_release);
g_error.clear();
g_state.store(STATE_CONNECTING, std::memory_order_release);
g_thread = std::thread(RunCapture, std::string(path ? path : ""), port, seconds, memPercent);
return 0;
}
ANG_CAP_API void ang_cap_stop(void) {
g_stopRequested.store(true, std::memory_order_release);
}
ANG_CAP_API int ang_cap_state(void) {
return g_state.load(std::memory_order_acquire);
}
ANG_CAP_API int64_t ang_cap_elapsed_ms(void) {
const int64_t frozen = g_frozenElapsedMs.load(std::memory_order_acquire);
if (frozen >= 0) return frozen;
if (g_state.load(std::memory_order_acquire) != STATE_RECORDING) return 0;
const int64_t startNs = g_recordStartNs.load(std::memory_order_acquire);
if (startNs == 0) return 0;
const int64_t nowNs = std::chrono::steady_clock::now().time_since_epoch().count();
return (nowNs - startNs) / 1000000;
}
ANG_CAP_API const char* ang_cap_error(void) {
std::lock_guard<std::mutex> lock(g_mutex);
g_errorSnapshot = g_error;
return g_errorSnapshot.c_str();
}
}
@@ -0,0 +1,5 @@
_ang_cap_start
_ang_cap_stop
_ang_cap_state
_ang_cap_elapsed_ms
_ang_cap_error
@@ -1,6 +1,11 @@
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <cstdlib>
#if defined(_WIN32)
#include <windows.h>
#endif
#include "tracy/TracyC.h"
#include "client/TracyProfiler.hpp"
@@ -50,4 +55,12 @@ TRACY_API void ang_section_setup(uint32_t category, const char* txt) {
tracy::Profiler::SectionSetup((uint16_t)category, "%s", txt);
}
TRACY_API int ang_set_env(const char* name, const char* value) {
#if defined(_WIN32)
return SetEnvironmentVariableA(name, value) ? 0 : 1;
#else
return setenv(name, value, 1);
#endif
}
}
@@ -3,6 +3,7 @@ package com.gtnewhorizons.angelica.tracy;
import com.gtnewhorizon.gtnhlib.reflect.Fields;
import com.gtnewhorizon.gtnhlib.reflect.Fields.LookupType;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.glsm.profiling.TracyBackend;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
@@ -11,14 +12,17 @@ import org.junit.jupiter.api.TestMethodOrder;
import org.junit.jupiter.api.condition.EnabledIf;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@EnabledIf("nativePresent")
@@ -42,6 +46,17 @@ class TracyBindingSmokeTest {
return false;
}
static boolean captureLibPresent() {
for (String platform : new String[] {"windows-x64", "linux-x64", "macos-x64", "macos-arm64"}) {
for (String lib : new String[] {"AngelicaTracyCapture.dll", "libAngelicaTracyCapture.so", "libAngelicaTracyCapture.dylib"}) {
if (TracyBindingSmokeTest.class.getResource("/natives/tracy/" + TracyTags.TRACY_VERSION + "/" + platform + "/" + lib) != null) {
return true;
}
}
}
return false;
}
private static synchronized TracyClientBackend backend() throws Exception {
if (backend == null) {
Fields.ofClass(SystemProperties.class).getField(LookupType.DECLARED, "TRACY_DIR", String.class).setValue(null, extractDir.toAbsolutePath().toString());
@@ -171,6 +186,73 @@ class TracyBindingSmokeTest {
@Test
@Order(7)
@EnabledIf("captureLibPresent")
void captureLifecycle(@TempDir Path tmp) throws Exception {
final TracyClientBackend b = backend();
final long srcLoc = b.internSrcLoc("capture-smoke", 0);
final Path file = tmp.resolve("t.tracy");
final Thread emitter = startEmitter(b, srcLoc, 1500L);
final String error = b.captureStart(file.toAbsolutePath().toString(), 1);
assertNull(error, "captureStart failed: " + error);
final int state = pollUntilDone(b);
emitter.join(5_000);
assertEquals(TracyBackend.CAPTURE_DONE, state, "capture did not finish, last state " + state + ": " + b.captureError());
assertTrue(Files.isRegularFile(file), "capture file missing: " + file);
assertTrue(Files.size(file) > 0, "capture file empty: " + file);
}
@Test
@Order(8)
@EnabledIf("captureLibPresent")
void captureReconnect(@TempDir Path tmp) throws Exception {
final TracyClientBackend b = backend();
final long srcLoc = b.internSrcLoc("capture-smoke-2", 0);
final Path file = tmp.resolve("t2.tracy");
final Thread emitter = startEmitter(b, srcLoc, 1500L);
final String error = b.captureStart(file.toAbsolutePath().toString(), 0);
assertNull(error, "captureStart failed: " + error);
Thread.sleep(500);
b.captureStop();
final int state = pollUntilDone(b);
emitter.join(5_000);
assertEquals(TracyBackend.CAPTURE_DONE, state, "capture did not finish, last state " + state + ": " + b.captureError());
assertTrue(Files.isRegularFile(file), "capture file missing: " + file);
assertTrue(Files.size(file) > 0, "capture file empty: " + file);
}
private static Thread startEmitter(TracyClientBackend b, long srcLoc, long durationMs) {
final Thread t = new Thread(() -> {
b.setCurrentThreadName("capture-emitter");
final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(durationMs);
while (System.nanoTime() < deadline) {
final long ctx = b.beginZone(srcLoc);
b.endZone(ctx);
b.frameMark();
}
}, "capture-emitter");
t.start();
return t;
}
private static int pollUntilDone(TracyClientBackend b) throws InterruptedException {
final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15);
int state = b.captureState();
while (state != TracyBackend.CAPTURE_DONE && state != TracyBackend.CAPTURE_FAILED && System.nanoTime() < deadline) {
Thread.sleep(50);
state = b.captureState();
}
return state;
}
@Test
@Order(9)
void shutdownFlushes() throws Exception {
backend().shutdown();
}
@@ -0,0 +1,41 @@
package com.gtnewhorizons.angelica.tracy;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.ServerSocket;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TracyPortsTest {
@Test
void autoPickSkipsAnOccupiedPort() throws IOException {
for (int port = 8086; port <= 8105; port++) {
try (ServerSocket held = new ServerSocket(port)) {
final int picked = TracyPorts.pick();
assertNotEquals(port, picked);
assertTrue(picked > 0);
return;
} catch (IOException ignored) {}
}
}
@Test
void parsePortAcceptsTheValidRange() {
assertEquals(1, TracyPorts.parsePort("1"));
assertEquals(65535, TracyPorts.parsePort("65535"));
assertEquals(9000, TracyPorts.parsePort("9000"));
}
@Test
void parsePortRejectsOutOfRangeAndGarbage() {
assertEquals(0, TracyPorts.parsePort("0"));
assertEquals(0, TracyPorts.parsePort("65536"));
assertEquals(0, TracyPorts.parsePort("-1"));
assertEquals(0, TracyPorts.parsePort("not a port"));
assertEquals(0, TracyPorts.parsePort(""));
}
}