feat: Migrate to Python 3.14 (closes #135)

Replace deprecated Python C API calls with modern PyConfig-based initialization:
- PySys_SetArgvEx() -> PyConfig.argv (deprecated since 3.11)
- Py_InspectFlag -> PyConfig.inspect (deprecated since 3.12)

Fix critical memory safety bugs discovered during migration:
- PyColor::from_arg() and PyVector::from_arg() now return new references
  instead of borrowed references, preventing use-after-free when callers
  call Py_DECREF on the result
- GameEngine::testTimers() now holds a local shared_ptr copy during
  callback execution, preventing use-after-free when timer callbacks
  call delTimer() on themselves

Fix double script execution bug with --exec flag:
- Scripts were running twice because GameEngine constructor executed them,
  then main.cpp deleted and recreated the engine
- Now reuses existing engine and just sets auto_exit_after_exec flag

Update test syntax to use keyword arguments for Frame/Caption constructors.

Test results: 127/130 passing (97.7%)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-11-26 17:48:12 -05:00
parent b173f59f22
commit 28396b65c9
14 changed files with 240 additions and 203 deletions

View File

@ -31,7 +31,7 @@ set(LINK_LIBS
# On Windows, add any additional libs and include directories
if(WIN32)
# Windows-specific Python library name (no dots)
list(APPEND LINK_LIBS python312)
list(APPEND LINK_LIBS python314)
# Add the necessary Windows-specific libraries and include directories
# include_directories(path_to_additional_includes)
# link_directories(path_to_additional_libs)
@ -39,7 +39,7 @@ if(WIN32)
include_directories(${CMAKE_SOURCE_DIR}/deps/platform/windows)
else()
# Unix/Linux specific libraries
list(APPEND LINK_LIBS python3.12 m dl util pthread)
list(APPEND LINK_LIBS python3.14 m dl util pthread)
include_directories(${CMAKE_SOURCE_DIR}/deps/platform/linux)
endif()

View File

@ -176,5 +176,5 @@ void CommandLineParser::print_help() {
}
void CommandLineParser::print_version() {
std::cout << "Python 3.12.0 (McRogueFace embedded)\n";
std::cout << "Python 3.14.0 (McRogueFace embedded)\n";
}

View File

@ -336,9 +336,14 @@ void GameEngine::testTimers()
auto it = timers.begin();
while (it != timers.end())
{
it->second->test(now);
// Keep a local copy of the timer to prevent use-after-free.
// If the callback calls delTimer(), the map entry gets replaced,
// but we need the Timer object to survive until test() returns.
auto timer = it->second;
timer->test(now);
// Remove timers that have been cancelled or are one-shot and fired
// Remove timers that have been cancelled or are one-shot and fired.
// Note: Check it->second (current map value) in case callback replaced it.
if (!it->second->getCallback() || it->second->getCallback() == Py_None)
{
it = timers.erase(it);

View File

@ -154,6 +154,7 @@ public:
void setWindowScale(float);
bool isHeadless() const { return headless; }
const McRogueFaceConfig& getConfig() const { return config; }
void setAutoExitAfterExec(bool enabled) { config.auto_exit_after_exec = enabled; }
void processEvent(const sf::Event& event);
// Window property accessors

View File

@ -397,10 +397,10 @@ PyStatus init_python(const char *program_name)
// search paths for python libs/modules/scripts
const wchar_t* str_arr[] = {
L"/scripts",
L"/lib/Python/lib.linux-x86_64-3.12",
L"/lib/Python/lib.linux-x86_64-3.14",
L"/lib/Python",
L"/lib/Python/Lib",
L"/venv/lib/python3.12/site-packages"
L"/venv/lib/python3.14/site-packages"
};
@ -419,7 +419,7 @@ PyStatus init_python(const char *program_name)
return status;
}
PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config, int argc, char** argv)
PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config)
{
// If Python is already initialized, just return success
if (Py_IsInitialized()) {
@ -435,21 +435,67 @@ PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config, in
PyConfig_SetString(&pyconfig, &pyconfig.stdio_errors, L"surrogateescape");
pyconfig.configure_c_stdio = 1;
// CRITICAL: Pass actual command line arguments to Python
status = PyConfig_SetBytesArgv(&pyconfig, argc, argv);
// Set interactive mode (replaces deprecated Py_InspectFlag)
if (config.interactive_mode) {
pyconfig.inspect = 1;
}
// Don't modify sys.path based on script location (replaces PySys_SetArgvEx updatepath=0)
pyconfig.safe_path = 1;
// Construct Python argv from config (replaces deprecated PySys_SetArgvEx)
// Python convention:
// - Script mode: argv[0] = script_path, argv[1:] = script_args
// - -c mode: argv[0] = "-c"
// - -m mode: argv[0] = module_name, argv[1:] = script_args
// - Interactive only: argv[0] = ""
std::vector<std::wstring> argv_storage;
if (!config.script_path.empty()) {
// Script execution: argv[0] = script path
argv_storage.push_back(config.script_path.wstring());
for (const auto& arg : config.script_args) {
std::wstring warg(arg.begin(), arg.end());
argv_storage.push_back(warg);
}
} else if (!config.python_command.empty()) {
// -c command: argv[0] = "-c"
argv_storage.push_back(L"-c");
} else if (!config.python_module.empty()) {
// -m module: argv[0] = module name
std::wstring wmodule(config.python_module.begin(), config.python_module.end());
argv_storage.push_back(wmodule);
for (const auto& arg : config.script_args) {
std::wstring warg(arg.begin(), arg.end());
argv_storage.push_back(warg);
}
} else {
// Interactive mode or no script: argv[0] = ""
argv_storage.push_back(L"");
}
// Build wchar_t* array for PyConfig
std::vector<wchar_t*> argv_ptrs;
for (auto& ws : argv_storage) {
argv_ptrs.push_back(const_cast<wchar_t*>(ws.c_str()));
}
status = PyConfig_SetWideStringList(&pyconfig, &pyconfig.argv,
argv_ptrs.size(), argv_ptrs.data());
if (PyStatus_Exception(status)) {
return status;
}
// Check if we're in a virtual environment
auto exe_path = std::filesystem::path(argv[0]);
auto exe_dir = exe_path.parent_path();
auto exe_wpath = executable_filename();
auto exe_path_fs = std::filesystem::path(exe_wpath);
auto exe_dir = exe_path_fs.parent_path();
auto venv_root = exe_dir.parent_path();
if (std::filesystem::exists(venv_root / "pyvenv.cfg")) {
// We're running from within a venv!
// Add venv's site-packages to module search paths
auto site_packages = venv_root / "lib" / "python3.12" / "site-packages";
auto site_packages = venv_root / "lib" / "python3.14" / "site-packages";
PyWideStringList_Append(&pyconfig.module_search_paths,
site_packages.wstring().c_str());
pyconfig.module_search_paths_set = 1;
@ -468,10 +514,10 @@ PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config, in
// search paths for python libs/modules/scripts
const wchar_t* str_arr[] = {
L"/scripts",
L"/lib/Python/lib.linux-x86_64-3.12",
L"/lib/Python/lib.linux-x86_64-3.14",
L"/lib/Python",
L"/lib/Python/Lib",
L"/venv/lib/python3.12/site-packages"
L"/venv/lib/python3.14/site-packages"
};
for(auto s : str_arr) {
@ -483,9 +529,7 @@ PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config, in
#endif
// Register mcrfpy module before initialization
if (!Py_IsInitialized()) {
PyImport_AppendInittab("mcrfpy", &PyInit_mcrfpy);
}
status = Py_InitializeFromConfig(&pyconfig);
PyConfig_Clear(&pyconfig);
@ -535,9 +579,9 @@ void McRFPy_API::api_init() {
//setSpriteTexture(0);
}
void McRFPy_API::api_init(const McRogueFaceConfig& config, int argc, char** argv) {
// Initialize Python with proper argv - this is CRITICAL
PyStatus status = init_python_with_config(config, argc, argv);
void McRFPy_API::api_init(const McRogueFaceConfig& config) {
// Initialize Python with proper argv constructed from config
PyStatus status = init_python_with_config(config);
if (PyStatus_Exception(status)) {
Py_ExitStatusException(status);
}

View File

@ -29,8 +29,8 @@ public:
//static void setSpriteTexture(int);
inline static GameEngine* game;
static void api_init();
static void api_init(const McRogueFaceConfig& config, int argc, char** argv);
static PyStatus init_python_with_config(const McRogueFaceConfig& config, int argc, char** argv);
static void api_init(const McRogueFaceConfig& config);
static PyStatus init_python_with_config(const McRogueFaceConfig& config);
static void api_shutdown();
// Python API functionality - use mcrfpy.* in scripts
//static PyObject* _drawSprite(PyObject*, PyObject*);

View File

@ -236,6 +236,7 @@ PyColorObject* PyColor::from_arg(PyObject* args)
// Check if args is already a Color instance
if (PyObject_IsInstance(args, (PyObject*)type.get())) {
Py_INCREF(args); // Return new reference so caller can safely DECREF
return (PyColorObject*)args;
}

View File

@ -1,6 +1,7 @@
#include "PyVector.h"
#include "PyObjectUtils.h"
#include "McRFPy_Doc.h"
#include "PyRAII.h"
#include <cmath>
PyGetSetDef PyVector::getsetters[] = {
@ -261,35 +262,46 @@ int PyVector::set_member(PyObject* obj, PyObject* value, void* closure)
PyVectorObject* PyVector::from_arg(PyObject* args)
{
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector");
if (PyObject_IsInstance(args, (PyObject*)type)) return (PyVectorObject*)args;
// Use RAII for type reference management
PyRAII::PyTypeRef type("Vector", McRFPy_API::mcrf_module);
if (!type) {
return NULL;
}
auto obj = (PyVectorObject*)type->tp_alloc(type, 0);
// Check if args is already a Vector instance
if (PyObject_IsInstance(args, (PyObject*)type.get())) {
Py_INCREF(args); // Return new reference so caller can safely DECREF
return (PyVectorObject*)args;
}
// Create new Vector object using RAII
PyRAII::PyObjectRef obj(type->tp_alloc(type.get(), 0), true);
if (!obj) {
return NULL;
}
// Handle different input types
if (PyTuple_Check(args)) {
// It's already a tuple, pass it directly to init
int err = init(obj, args, NULL);
int err = init((PyVectorObject*)obj.get(), args, NULL);
if (err) {
Py_DECREF(obj);
// obj will be automatically cleaned up when it goes out of scope
return NULL;
}
} else {
// Wrap single argument in a tuple for init
PyObject* tuple = PyTuple_Pack(1, args);
PyRAII::PyObjectRef tuple(PyTuple_Pack(1, args), true);
if (!tuple) {
Py_DECREF(obj);
return NULL;
}
int err = init(obj, tuple, NULL);
Py_DECREF(tuple);
int err = init((PyVectorObject*)obj.get(), tuple.get(), NULL);
if (err) {
Py_DECREF(obj);
return NULL;
}
}
return obj;
// Release ownership and return
return (PyVectorObject*)obj.release();
}
// Arithmetic operations

View File

@ -11,7 +11,7 @@
// Forward declarations
int run_game_engine(const McRogueFaceConfig& config);
int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv[]);
int run_python_interpreter(const McRogueFaceConfig& config);
int main(int argc, char* argv[])
{
@ -31,7 +31,7 @@ int main(int argc, char* argv[])
// Initialize based on configuration
if (config.python_mode) {
return run_python_interpreter(config, argc, argv);
return run_python_interpreter(config);
} else {
return run_game_engine(config);
}
@ -52,13 +52,13 @@ int run_game_engine(const McRogueFaceConfig& config)
return 0;
}
int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv[])
int run_python_interpreter(const McRogueFaceConfig& config)
{
// Create a game engine with the requested configuration
GameEngine* engine = new GameEngine(config);
// Initialize Python with configuration
McRFPy_API::init_python_with_config(config, argc, argv);
// Initialize Python with configuration (argv is constructed from config)
McRFPy_API::init_python_with_config(config);
// Import mcrfpy module and store reference
McRFPy_API::mcrf_module = PyImport_ImportModule("mcrfpy");
@ -116,17 +116,10 @@ int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv
}
}
else if (!config.python_module.empty()) {
// Execute module using runpy
// Execute module using runpy (sys.argv already set at init time)
std::string run_module_code =
"import sys\n"
"import runpy\n"
"sys.argv = ['" + config.python_module + "'";
for (const auto& arg : config.script_args) {
run_module_code += ", '" + arg + "'";
}
run_module_code += "]\n";
run_module_code += "runpy.run_module('" + config.python_module + "', run_name='__main__', alter_sys=True)\n";
"runpy.run_module('" + config.python_module + "', run_name='__main__', alter_sys=True)\n";
int result = PyRun_SimpleString(run_module_code.c_str());
McRFPy_API::api_shutdown();
@ -134,7 +127,7 @@ int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv
return result;
}
else if (!config.script_path.empty()) {
// Execute script file
// Execute script file (sys.argv already set at init time)
FILE* fp = fopen(config.script_path.string().c_str(), "r");
if (!fp) {
std::cerr << "mcrogueface: can't open file '" << config.script_path << "': ";
@ -142,23 +135,9 @@ int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv
return 1;
}
// Set up sys.argv
wchar_t** python_argv = new wchar_t*[config.script_args.size() + 1];
python_argv[0] = Py_DecodeLocale(config.script_path.string().c_str(), nullptr);
for (size_t i = 0; i < config.script_args.size(); i++) {
python_argv[i + 1] = Py_DecodeLocale(config.script_args[i].c_str(), nullptr);
}
PySys_SetArgvEx(config.script_args.size() + 1, python_argv, 0);
int result = PyRun_SimpleFile(fp, config.script_path.string().c_str());
fclose(fp);
// Clean up
for (size_t i = 0; i <= config.script_args.size(); i++) {
PyMem_RawFree(python_argv[i]);
}
delete[] python_argv;
if (config.interactive_mode) {
// Even if script had SystemExit, continue to interactive mode
if (result != 0) {
@ -197,22 +176,18 @@ int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv
}
else if (config.interactive_mode) {
// Interactive Python interpreter (only if explicitly requested with -i)
Py_InspectFlag = 1;
// Note: pyconfig.inspect is set at init time based on config.interactive_mode
PyRun_InteractiveLoop(stdin, "<stdin>");
McRFPy_API::api_shutdown();
delete engine;
return 0;
}
else if (!config.exec_scripts.empty()) {
// With --exec, run the game engine after scripts execute
// In headless mode, auto-exit when no timers remain
McRogueFaceConfig mutable_config = config;
if (mutable_config.headless) {
mutable_config.auto_exit_after_exec = true;
// With --exec, scripts were already executed by the first GameEngine constructor.
// Just configure auto-exit and run the existing engine to preserve timers/state.
if (config.headless) {
engine->setAutoExitAfterExec(true);
}
delete engine;
engine = new GameEngine(mutable_config);
McRFPy_API::game = engine;
engine->run();
McRFPy_API::api_shutdown();
delete engine;

View File

@ -2,20 +2,44 @@
"""Test UIFrame clipping functionality"""
import mcrfpy
from mcrfpy import Color, Frame, Caption, Vector
from mcrfpy import Color, Frame, Caption
import sys
# Module-level state to avoid closures
_test_state = {}
def take_second_screenshot(runtime):
"""Take final screenshot and exit"""
mcrfpy.delTimer("screenshot2")
from mcrfpy import automation
automation.screenshot("frame_clipping_animated.png")
print("\nTest completed successfully!")
print("Screenshots saved:")
print(" - frame_clipping_test.png (initial state)")
print(" - frame_clipping_animated.png (with animation)")
sys.exit(0)
def animate_frames(runtime):
"""Animate frames to demonstrate clipping"""
mcrfpy.delTimer("animate")
scene = mcrfpy.sceneUI("test")
# Move child frames
parent1 = scene[0]
parent2 = scene[1]
parent1.children[1].x = 50
parent2.children[1].x = 50
mcrfpy.setTimer("screenshot2", take_second_screenshot, 500)
def test_clipping(runtime):
"""Test that clip_children property works correctly"""
mcrfpy.delTimer("test_clipping")
print("Testing UIFrame clipping functionality...")
# Create test scene
scene = mcrfpy.sceneUI("test")
# Create parent frame with clipping disabled (default)
parent1 = Frame(50, 50, 200, 150,
parent1 = Frame(pos=(50, 50), size=(200, 150),
fill_color=Color(100, 100, 200),
outline_color=Color(255, 255, 255),
outline=2)
@ -23,7 +47,7 @@ def test_clipping(runtime):
scene.append(parent1)
# Create parent frame with clipping enabled
parent2 = Frame(300, 50, 200, 150,
parent2 = Frame(pos=(300, 50), size=(200, 150),
fill_color=Color(200, 100, 100),
outline_color=Color(255, 255, 255),
outline=2)
@ -32,48 +56,48 @@ def test_clipping(runtime):
scene.append(parent2)
# Add captions to both frames
caption1 = Caption(10, 10, "This text should overflow the frame bounds")
caption1 = Caption(text="This text should overflow the frame bounds", pos=(10, 10))
caption1.font_size = 16
caption1.fill_color = Color(255, 255, 255)
parent1.children.append(caption1)
caption2 = Caption(10, 10, "This text should be clipped to frame bounds")
caption2 = Caption(text="This text should be clipped to frame bounds", pos=(10, 10))
caption2.font_size = 16
caption2.fill_color = Color(255, 255, 255)
parent2.children.append(caption2)
# Add child frames that extend beyond parent bounds
child1 = Frame(150, 100, 100, 100,
child1 = Frame(pos=(150, 100), size=(100, 100),
fill_color=Color(50, 255, 50),
outline_color=Color(0, 0, 0),
outline=1)
parent1.children.append(child1)
child2 = Frame(150, 100, 100, 100,
child2 = Frame(pos=(150, 100), size=(100, 100),
fill_color=Color(50, 255, 50),
outline_color=Color(0, 0, 0),
outline=1)
parent2.children.append(child2)
# Add caption to show clip state
status = Caption(50, 250,
f"Left frame: clip_children={parent1.clip_children}\n"
f"Right frame: clip_children={parent2.clip_children}")
status = Caption(text=f"Left frame: clip_children={parent1.clip_children}\n"
f"Right frame: clip_children={parent2.clip_children}",
pos=(50, 250))
status.font_size = 14
status.fill_color = Color(255, 255, 255)
scene.append(status)
# Add instructions
instructions = Caption(50, 300,
"Left: Children should overflow (no clipping)\n"
instructions = Caption(text="Left: Children should overflow (no clipping)\n"
"Right: Children should be clipped to frame bounds\n"
"Press 'c' to toggle clipping on left frame")
"Press 'c' to toggle clipping on left frame",
pos=(50, 300))
instructions.font_size = 12
instructions.fill_color = Color(200, 200, 200)
scene.append(instructions)
# Take screenshot
from mcrfpy import Window, automation
from mcrfpy import automation
automation.screenshot("frame_clipping_test.png")
print(f"Parent1 clip_children: {parent1.clip_children}")
@ -85,50 +109,25 @@ def test_clipping(runtime):
# Verify the property setter works
try:
parent1.clip_children = "not a bool" # Should raise TypeError
parent1.clip_children = "not a bool"
print("ERROR: clip_children accepted non-boolean value")
except TypeError as e:
print(f"PASS: clip_children correctly rejected non-boolean: {e}")
# Test with animations
def animate_frames(runtime):
mcrfpy.delTimer("animate")
# Animate child frames to show clipping in action
# Note: For now, just move the frames manually to demonstrate clipping
parent1.children[1].x = 50 # Move child frame
parent2.children[1].x = 50 # Move child frame
# Take another screenshot after starting animation
mcrfpy.setTimer("screenshot2", take_second_screenshot, 500)
def take_second_screenshot(runtime):
mcrfpy.delTimer("screenshot2")
automation.screenshot("frame_clipping_animated.png")
print("\nTest completed successfully!")
print("Screenshots saved:")
print(" - frame_clipping_test.png (initial state)")
print(" - frame_clipping_animated.png (with animation)")
sys.exit(0)
# Start animation after a short delay
mcrfpy.setTimer("animate", animate_frames, 100)
def handle_keypress(key, modifiers):
if key == "c":
scene = mcrfpy.sceneUI("test")
parent1 = scene[0]
parent1.clip_children = not parent1.clip_children
print(f"Toggled parent1 clip_children to: {parent1.clip_children}")
# Main execution
print("Creating test scene...")
mcrfpy.createScene("test")
mcrfpy.setScene("test")
# Set up keyboard handler to toggle clipping
def handle_keypress(key, modifiers):
if key == "c":
scene = mcrfpy.sceneUI("test")
parent1 = scene[0] # First frame
parent1.clip_children = not parent1.clip_children
print(f"Toggled parent1 clip_children to: {parent1.clip_children}")
mcrfpy.keypressScene(handle_keypress)
# Schedule the test
mcrfpy.setTimer("test_clipping", test_clipping, 100)
print("Test scheduled, running...")

View File

@ -15,7 +15,7 @@ def test_nested_clipping(runtime):
scene = mcrfpy.sceneUI("test")
# Create outer frame with clipping enabled
outer = Frame(50, 50, 400, 300,
outer = Frame(pos=(50, 50), size=(400, 300),
fill_color=Color(50, 50, 150),
outline_color=Color(255, 255, 255),
outline=3)
@ -24,7 +24,7 @@ def test_nested_clipping(runtime):
scene.append(outer)
# Create inner frame that extends beyond outer bounds
inner = Frame(200, 150, 300, 200,
inner = Frame(pos=(200, 150), size=(300, 200),
fill_color=Color(150, 50, 50),
outline_color=Color(255, 255, 0),
outline=2)
@ -34,13 +34,13 @@ def test_nested_clipping(runtime):
# Add content to inner frame that extends beyond its bounds
for i in range(5):
caption = Caption(10, 30 * i, f"Line {i+1}: This text should be double-clipped")
caption = Caption(text=f"Line {i+1}: This text should be double-clipped", pos=(10, 30 * i))
caption.font_size = 14
caption.fill_color = Color(255, 255, 255)
inner.children.append(caption)
# Add a child frame to inner that extends way out
deeply_nested = Frame(250, 100, 200, 150,
deeply_nested = Frame(pos=(250, 100), size=(200, 150),
fill_color=Color(50, 150, 50),
outline_color=Color(255, 0, 255),
outline=2)
@ -48,11 +48,11 @@ def test_nested_clipping(runtime):
inner.children.append(deeply_nested)
# Add status text
status = Caption(50, 380,
"Nested clipping test:\n"
status = Caption(text="Nested clipping test:\n"
"- Blue outer frame clips red inner frame\n"
"- Red inner frame clips green deeply nested frame\n"
"- All text should be clipped to frame bounds")
"- All text should be clipped to frame bounds",
pos=(50, 380))
status.font_size = 12
status.fill_color = Color(200, 200, 200)
scene.append(status)

View File

@ -10,17 +10,17 @@ call_count = 0
pause_test_count = 0
cancel_test_count = 0
def timer_callback(elapsed_ms):
def timer_callback(timer, runtime):
global call_count
call_count += 1
print(f"Timer fired! Count: {call_count}, Elapsed: {elapsed_ms}ms")
print(f"Timer fired! Count: {call_count}, Runtime: {runtime}ms")
def pause_test_callback(elapsed_ms):
def pause_test_callback(timer, runtime):
global pause_test_count
pause_test_count += 1
print(f"Pause test timer: {pause_test_count}")
def cancel_test_callback(elapsed_ms):
def cancel_test_callback(timer, runtime):
global cancel_test_count
cancel_test_count += 1
print(f"Cancel test timer: {cancel_test_count} - This should only print once!")
@ -76,7 +76,7 @@ def run_tests(runtime):
# Test 4: Test interval modification
print("\nTest 4: Testing interval modification")
def interval_test(runtime):
def interval_test(timer, runtime):
print(f" Interval test fired at {runtime}ms")
timer4 = mcrfpy.Timer("interval_test", interval_test, 1000)
@ -98,12 +98,12 @@ def run_tests(runtime):
print("\nTest 6: Testing restart functionality")
restart_count = [0]
def restart_test(runtime):
def restart_test(timer, runtime):
restart_count[0] += 1
print(f" Restart test: {restart_count[0]}")
if restart_count[0] == 2:
print(" Restarting timer...")
timer5.restart()
timer.restart()
timer5 = mcrfpy.Timer("restart_test", restart_test, 400)

View File

@ -24,7 +24,7 @@ def test_viewport_modes(runtime):
# Create a frame that fills the game resolution to show boundaries
game_res = window.game_resolution
boundary = Frame(0, 0, game_res[0], game_res[1],
boundary = Frame(pos=(0, 0), size=(game_res[0], game_res[1]),
fill_color=Color(50, 50, 100),
outline_color=Color(255, 255, 255),
outline=2)
@ -41,13 +41,13 @@ def test_viewport_modes(runtime):
]
for x, y, label in corners:
corner = Frame(x, y, corner_size, corner_size,
corner = Frame(pos=(x, y), size=(corner_size, corner_size),
fill_color=Color(255, 100, 100),
outline_color=Color(255, 255, 255),
outline=1)
scene.append(corner)
text = Caption(x + 5, y + 5, label)
text = Caption(text=label, pos=(x + 5, y + 5))
text.font_size = 20
text.fill_color = Color(255, 255, 255)
scene.append(text)
@ -55,28 +55,28 @@ def test_viewport_modes(runtime):
# Add center crosshair
center_x = game_res[0] // 2
center_y = game_res[1] // 2
h_line = Frame(center_x - 50, center_y - 1, 100, 2,
h_line = Frame(pos=(center_x - 50, center_y - 1), size=(100, 2),
fill_color=Color(255, 255, 0))
v_line = Frame(center_x - 1, center_y - 50, 2, 100,
v_line = Frame(pos=(center_x - 1, center_y - 50), size=(2, 100),
fill_color=Color(255, 255, 0))
scene.append(h_line)
scene.append(v_line)
# Add mode indicator
mode_text = Caption(10, 10, f"Mode: {window.scaling_mode}")
mode_text = Caption(text=f"Mode: {window.scaling_mode}", pos=(10, 10))
mode_text.font_size = 24
mode_text.fill_color = Color(255, 255, 255)
mode_text.name = "mode_text"
scene.append(mode_text)
# Add instructions
instructions = Caption(10, 40,
"Press 1: Center mode (1:1 pixels)\n"
instructions = Caption(text="Press 1: Center mode (1:1 pixels)\n"
"Press 2: Stretch mode (fill window)\n"
"Press 3: Fit mode (maintain aspect ratio)\n"
"Press R: Change resolution\n"
"Press G: Change game resolution\n"
"Press Esc: Exit")
"Press Esc: Exit",
pos=(10, 40))
instructions.font_size = 14
instructions.fill_color = Color(200, 200, 200)
scene.append(instructions)

View File

@ -21,7 +21,7 @@ def test_viewport_visual(runtime):
game_res = window.game_resolution
# Full boundary frame
boundary = Frame(0, 0, game_res[0], game_res[1],
boundary = Frame(pos=(0, 0), size=(game_res[0], game_res[1]),
fill_color=Color(40, 40, 80),
outline_color=Color(255, 255, 0),
outline=3)
@ -44,13 +44,13 @@ def test_viewport_visual(runtime):
labels = ["TL", "TR", "BL", "BR"]
for (x, y), color, label in zip(positions, colors, labels):
corner = Frame(x, y, corner_size, corner_size,
corner = Frame(pos=(x, y), size=(corner_size, corner_size),
fill_color=color,
outline_color=Color(255, 255, 255),
outline=2)
scene.append(corner)
text = Caption(x + 10, y + 10, label)
text = Caption(text=label, pos=(x + 10, y + 10))
text.font_size = 32
text.fill_color = Color(0, 0, 0)
scene.append(text)
@ -58,23 +58,23 @@ def test_viewport_visual(runtime):
# Center crosshair
center_x = game_res[0] // 2
center_y = game_res[1] // 2
h_line = Frame(0, center_y - 1, game_res[0], 2,
h_line = Frame(pos=(0, center_y - 1), size=(game_res[0], 2),
fill_color=Color(255, 255, 255, 128))
v_line = Frame(center_x - 1, 0, 2, game_res[1],
v_line = Frame(pos=(center_x - 1, 0), size=(2, game_res[1]),
fill_color=Color(255, 255, 255, 128))
scene.append(h_line)
scene.append(v_line)
# Mode text
mode_text = Caption(center_x - 100, center_y - 50,
f"Mode: {window.scaling_mode}")
mode_text = Caption(text=f"Mode: {window.scaling_mode}",
pos=(center_x - 100, center_y - 50))
mode_text.font_size = 36
mode_text.fill_color = Color(255, 255, 255)
scene.append(mode_text)
# Resolution text
res_text = Caption(center_x - 150, center_y + 10,
f"Game: {game_res[0]}x{game_res[1]}")
res_text = Caption(text=f"Game: {game_res[0]}x{game_res[1]}",
pos=(center_x - 150, center_y + 10))
res_text.font_size = 24
res_text.fill_color = Color(200, 200, 200)
scene.append(res_text)