Compare commits

...

2 commits

Author SHA1 Message Date
a703bce196 Merge branch 'origin/master' - combine double-execution fixes
Both branches fixed the --exec double-execution bug with complementary approaches:
- origin/master: Added executeStartupScripts() method for cleaner separation
- HEAD: Avoided engine recreation to preserve state

This merge keeps the best of both: executeStartupScripts() called on the
existing engine without recreation.

Also accepts deletion of flaky test_viewport_visual.py from origin/master.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 18:03:15 -05:00
28396b65c9 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>
2025-11-26 17:48:12 -05:00
13 changed files with 413 additions and 178 deletions

View file

@ -31,7 +31,7 @@ set(LINK_LIBS
# On Windows, add any additional libs and include directories # On Windows, add any additional libs and include directories
if(WIN32) if(WIN32)
# Windows-specific Python library name (no dots) # 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 # Add the necessary Windows-specific libraries and include directories
# include_directories(path_to_additional_includes) # include_directories(path_to_additional_includes)
# link_directories(path_to_additional_libs) # link_directories(path_to_additional_libs)
@ -39,7 +39,7 @@ if(WIN32)
include_directories(${CMAKE_SOURCE_DIR}/deps/platform/windows) include_directories(${CMAKE_SOURCE_DIR}/deps/platform/windows)
else() else()
# Unix/Linux specific libraries # 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) include_directories(${CMAKE_SOURCE_DIR}/deps/platform/linux)
endif() endif()

View file

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

View file

@ -344,9 +344,14 @@ void GameEngine::testTimers()
auto it = timers.begin(); auto it = timers.begin();
while (it != timers.end()) 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) if (!it->second->getCallback() || it->second->getCallback() == Py_None)
{ {
it = timers.erase(it); it = timers.erase(it);

View file

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

View file

@ -397,10 +397,10 @@ PyStatus init_python(const char *program_name)
// search paths for python libs/modules/scripts // search paths for python libs/modules/scripts
const wchar_t* str_arr[] = { const wchar_t* str_arr[] = {
L"/scripts", 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",
L"/lib/Python/Lib", 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; 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 Python is already initialized, just return success
if (Py_IsInitialized()) { 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_SetString(&pyconfig, &pyconfig.stdio_errors, L"surrogateescape");
pyconfig.configure_c_stdio = 1; pyconfig.configure_c_stdio = 1;
// CRITICAL: Pass actual command line arguments to Python // Set interactive mode (replaces deprecated Py_InspectFlag)
status = PyConfig_SetBytesArgv(&pyconfig, argc, argv); 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)) { if (PyStatus_Exception(status)) {
return status; return status;
} }
// Check if we're in a virtual environment // Check if we're in a virtual environment
auto exe_path = std::filesystem::path(argv[0]); auto exe_wpath = executable_filename();
auto exe_dir = exe_path.parent_path(); auto exe_path_fs = std::filesystem::path(exe_wpath);
auto exe_dir = exe_path_fs.parent_path();
auto venv_root = exe_dir.parent_path(); auto venv_root = exe_dir.parent_path();
if (std::filesystem::exists(venv_root / "pyvenv.cfg")) { if (std::filesystem::exists(venv_root / "pyvenv.cfg")) {
// We're running from within a venv! // We're running from within a venv!
// Add venv's site-packages to module search paths // 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, PyWideStringList_Append(&pyconfig.module_search_paths,
site_packages.wstring().c_str()); site_packages.wstring().c_str());
pyconfig.module_search_paths_set = 1; 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 // search paths for python libs/modules/scripts
const wchar_t* str_arr[] = { const wchar_t* str_arr[] = {
L"/scripts", 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",
L"/lib/Python/Lib", L"/lib/Python/Lib",
L"/venv/lib/python3.12/site-packages" L"/venv/lib/python3.14/site-packages"
}; };
for(auto s : str_arr) { for(auto s : str_arr) {
@ -483,9 +529,7 @@ PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config, in
#endif #endif
// Register mcrfpy module before initialization // Register mcrfpy module before initialization
if (!Py_IsInitialized()) { PyImport_AppendInittab("mcrfpy", &PyInit_mcrfpy);
PyImport_AppendInittab("mcrfpy", &PyInit_mcrfpy);
}
status = Py_InitializeFromConfig(&pyconfig); status = Py_InitializeFromConfig(&pyconfig);
PyConfig_Clear(&pyconfig); PyConfig_Clear(&pyconfig);
@ -535,9 +579,9 @@ void McRFPy_API::api_init() {
//setSpriteTexture(0); //setSpriteTexture(0);
} }
void McRFPy_API::api_init(const McRogueFaceConfig& config, int argc, char** argv) { void McRFPy_API::api_init(const McRogueFaceConfig& config) {
// Initialize Python with proper argv - this is CRITICAL // Initialize Python with proper argv constructed from config
PyStatus status = init_python_with_config(config, argc, argv); PyStatus status = init_python_with_config(config);
if (PyStatus_Exception(status)) { if (PyStatus_Exception(status)) {
Py_ExitStatusException(status); Py_ExitStatusException(status);
} }

View file

@ -29,8 +29,8 @@ public:
//static void setSpriteTexture(int); //static void setSpriteTexture(int);
inline static GameEngine* game; inline static GameEngine* game;
static void api_init(); static void api_init();
static void api_init(const McRogueFaceConfig& config, int argc, char** argv); static void api_init(const McRogueFaceConfig& config);
static PyStatus init_python_with_config(const McRogueFaceConfig& config, int argc, char** argv); static PyStatus init_python_with_config(const McRogueFaceConfig& config);
static void api_shutdown(); static void api_shutdown();
// Python API functionality - use mcrfpy.* in scripts // Python API functionality - use mcrfpy.* in scripts
//static PyObject* _drawSprite(PyObject*, PyObject*); //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 // Check if args is already a Color instance
if (PyObject_IsInstance(args, (PyObject*)type.get())) { if (PyObject_IsInstance(args, (PyObject*)type.get())) {
Py_INCREF(args); // Return new reference so caller can safely DECREF
return (PyColorObject*)args; return (PyColorObject*)args;
} }

View file

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

View file

@ -11,7 +11,7 @@
// Forward declarations // Forward declarations
int run_game_engine(const McRogueFaceConfig& config); 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[]) int main(int argc, char* argv[])
{ {
@ -31,7 +31,7 @@ int main(int argc, char* argv[])
// Initialize based on configuration // Initialize based on configuration
if (config.python_mode) { if (config.python_mode) {
return run_python_interpreter(config, argc, argv); return run_python_interpreter(config);
} else { } else {
return run_game_engine(config); return run_game_engine(config);
} }
@ -52,13 +52,13 @@ int run_game_engine(const McRogueFaceConfig& config)
return 0; 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 // Create a game engine with the requested configuration
GameEngine* engine = new GameEngine(config); GameEngine* engine = new GameEngine(config);
// Initialize Python with configuration // Initialize Python with configuration (argv is constructed from config)
McRFPy_API::init_python_with_config(config, argc, argv); McRFPy_API::init_python_with_config(config);
// Import mcrfpy module and store reference // Import mcrfpy module and store reference
McRFPy_API::mcrf_module = PyImport_ImportModule("mcrfpy"); 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()) { 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 = std::string run_module_code =
"import sys\n"
"import runpy\n" "import runpy\n"
"sys.argv = ['" + config.python_module + "'"; "runpy.run_module('" + config.python_module + "', run_name='__main__', alter_sys=True)\n";
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";
int result = PyRun_SimpleString(run_module_code.c_str()); int result = PyRun_SimpleString(run_module_code.c_str());
McRFPy_API::api_shutdown(); McRFPy_API::api_shutdown();
@ -134,7 +127,7 @@ int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv
return result; return result;
} }
else if (!config.script_path.empty()) { 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"); FILE* fp = fopen(config.script_path.string().c_str(), "r");
if (!fp) { if (!fp) {
std::cerr << "mcrogueface: can't open file '" << config.script_path << "': "; 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; 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()); int result = PyRun_SimpleFile(fp, config.script_path.string().c_str());
fclose(fp); 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) { if (config.interactive_mode) {
// Even if script had SystemExit, continue to interactive mode // Even if script had SystemExit, continue to interactive mode
if (result != 0) { if (result != 0) {
@ -197,23 +176,18 @@ int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv
} }
else if (config.interactive_mode) { else if (config.interactive_mode) {
// Interactive Python interpreter (only if explicitly requested with -i) // 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>"); PyRun_InteractiveLoop(stdin, "<stdin>");
McRFPy_API::api_shutdown(); McRFPy_API::api_shutdown();
delete engine; delete engine;
return 0; return 0;
} }
else if (!config.exec_scripts.empty()) { else if (!config.exec_scripts.empty()) {
// With --exec, run the game engine after scripts execute // Execute startup scripts on the existing engine (not in constructor to prevent double-execution)
// In headless mode, auto-exit when no timers remain engine->executeStartupScripts();
McRogueFaceConfig mutable_config = config; if (config.headless) {
if (mutable_config.headless) { engine->setAutoExitAfterExec(true);
mutable_config.auto_exit_after_exec = true;
} }
delete engine;
engine = new GameEngine(mutable_config);
McRFPy_API::game = engine;
engine->executeStartupScripts(); // Execute --exec scripts ONCE here
engine->run(); engine->run();
McRFPy_API::api_shutdown(); McRFPy_API::api_shutdown();
delete engine; delete engine;

View file

@ -3,20 +3,43 @@
import mcrfpy import mcrfpy
from mcrfpy import Color, Frame, Caption from mcrfpy import Color, Frame, Caption
from mcrfpy import automation
import sys 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): def test_clipping(runtime):
"""Test that clip_children property works correctly""" """Test that clip_children property works correctly"""
mcrfpy.delTimer("test_clipping") mcrfpy.delTimer("test_clipping")
print("Testing UIFrame clipping functionality...") print("Testing UIFrame clipping functionality...")
# Create test scene
scene = mcrfpy.sceneUI("test") scene = mcrfpy.sceneUI("test")
# Create parent frame with clipping disabled (default) # Create parent frame with clipping disabled (default)
parent1 = Frame(x=50, y=50, w=200, h=150, parent1 = Frame(pos=(50, 50), size=(200, 150),
fill_color=Color(100, 100, 200), fill_color=Color(100, 100, 200),
outline_color=Color(255, 255, 255), outline_color=Color(255, 255, 255),
outline=2) outline=2)
@ -24,7 +47,7 @@ def test_clipping(runtime):
scene.append(parent1) scene.append(parent1)
# Create parent frame with clipping enabled # Create parent frame with clipping enabled
parent2 = Frame(x=300, y=50, w=200, h=150, parent2 = Frame(pos=(300, 50), size=(200, 150),
fill_color=Color(200, 100, 100), fill_color=Color(200, 100, 100),
outline_color=Color(255, 255, 255), outline_color=Color(255, 255, 255),
outline=2) outline=2)
@ -33,30 +56,48 @@ def test_clipping(runtime):
scene.append(parent2) scene.append(parent2)
# Add captions to both frames # Add captions to both frames
caption1 = Caption(text="This text should overflow", x=10, y=10) caption1 = Caption(text="This text should overflow the frame bounds", pos=(10, 10))
caption1.font_size = 16 caption1.font_size = 16
caption1.fill_color = Color(255, 255, 255) caption1.fill_color = Color(255, 255, 255)
parent1.children.append(caption1) parent1.children.append(caption1)
caption2 = Caption(text="This text should be clipped", x=10, y=10) caption2 = Caption(text="This text should be clipped to frame bounds", pos=(10, 10))
caption2.font_size = 16 caption2.font_size = 16
caption2.fill_color = Color(255, 255, 255) caption2.fill_color = Color(255, 255, 255)
parent2.children.append(caption2) parent2.children.append(caption2)
# Add child frames that extend beyond parent bounds # Add child frames that extend beyond parent bounds
child1 = Frame(x=150, y=100, w=100, h=100, child1 = Frame(pos=(150, 100), size=(100, 100),
fill_color=Color(50, 255, 50), fill_color=Color(50, 255, 50),
outline_color=Color(0, 0, 0), outline_color=Color(0, 0, 0),
outline=1) outline=1)
parent1.children.append(child1) parent1.children.append(child1)
child2 = Frame(x=150, y=100, w=100, h=100, child2 = Frame(pos=(150, 100), size=(100, 100),
fill_color=Color(50, 255, 50), fill_color=Color(50, 255, 50),
outline_color=Color(0, 0, 0), outline_color=Color(0, 0, 0),
outline=1) outline=1)
parent2.children.append(child2) parent2.children.append(child2)
# Add caption to show clip state
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(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",
pos=(50, 300))
instructions.font_size = 12
instructions.fill_color = Color(200, 200, 200)
scene.append(instructions)
# Take screenshot # Take screenshot
from mcrfpy import automation
automation.screenshot("frame_clipping_test.png") automation.screenshot("frame_clipping_test.png")
print(f"Parent1 clip_children: {parent1.clip_children}") print(f"Parent1 clip_children: {parent1.clip_children}")
@ -68,21 +109,25 @@ def test_clipping(runtime):
# Verify the property setter works # Verify the property setter works
try: try:
parent1.clip_children = "not a bool" # Should raise TypeError parent1.clip_children = "not a bool"
print("ERROR: clip_children accepted non-boolean value") print("ERROR: clip_children accepted non-boolean value")
sys.exit(1)
except TypeError as e: except TypeError as e:
print(f"PASS: clip_children correctly rejected non-boolean: {e}") print(f"PASS: clip_children correctly rejected non-boolean: {e}")
print("\nTest completed successfully!") # Start animation after a short delay
sys.exit(0) 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 # Main execution
print("Creating test scene...") print("Creating test scene...")
mcrfpy.createScene("test") mcrfpy.createScene("test")
mcrfpy.setScene("test") mcrfpy.setScene("test")
mcrfpy.keypressScene(handle_keypress)
# Schedule the test
mcrfpy.setTimer("test_clipping", test_clipping, 100) mcrfpy.setTimer("test_clipping", test_clipping, 100)
print("Test scheduled, running...") print("Test scheduled, running...")

View file

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

View file

@ -11,19 +11,16 @@ pause_test_count = 0
cancel_test_count = 0 cancel_test_count = 0
def timer_callback(timer, runtime): def timer_callback(timer, runtime):
"""Timer object callbacks receive (timer, runtime)"""
global call_count global call_count
call_count += 1 call_count += 1
print(f"Timer fired! Count: {call_count}, Runtime: {runtime}ms") print(f"Timer fired! Count: {call_count}, Runtime: {runtime}ms")
def pause_test_callback(timer, runtime): def pause_test_callback(timer, runtime):
"""Timer object callbacks receive (timer, runtime)"""
global pause_test_count global pause_test_count
pause_test_count += 1 pause_test_count += 1
print(f"Pause test timer: {pause_test_count}") print(f"Pause test timer: {pause_test_count}")
def cancel_test_callback(timer, runtime): def cancel_test_callback(timer, runtime):
"""Timer object callbacks receive (timer, runtime)"""
global cancel_test_count global cancel_test_count
cancel_test_count += 1 cancel_test_count += 1
print(f"Cancel test timer: {cancel_test_count} - This should only print once!") print(f"Cancel test timer: {cancel_test_count} - This should only print once!")
@ -49,7 +46,6 @@ def run_tests(runtime):
# Schedule pause after 250ms # Schedule pause after 250ms
def pause_timer2(runtime): def pause_timer2(runtime):
mcrfpy.delTimer("pause_timer2") # Prevent re-entry
print(" Pausing timer2...") print(" Pausing timer2...")
timer2.pause() timer2.pause()
print(f" Timer2 paused: {timer2.paused}") print(f" Timer2 paused: {timer2.paused}")
@ -57,7 +53,6 @@ def run_tests(runtime):
# Schedule resume after another 400ms # Schedule resume after another 400ms
def resume_timer2(runtime): def resume_timer2(runtime):
mcrfpy.delTimer("resume_timer2") # Prevent re-entry
print(" Resuming timer2...") print(" Resuming timer2...")
timer2.resume() timer2.resume()
print(f" Timer2 paused: {timer2.paused}") print(f" Timer2 paused: {timer2.paused}")
@ -73,7 +68,6 @@ def run_tests(runtime):
# Cancel after 350ms (should fire once) # Cancel after 350ms (should fire once)
def cancel_timer3(runtime): def cancel_timer3(runtime):
mcrfpy.delTimer("cancel_timer3") # Prevent re-entry
print(" Canceling timer3...") print(" Canceling timer3...")
timer3.cancel() timer3.cancel()
print(" Timer3 canceled") print(" Timer3 canceled")
@ -90,16 +84,13 @@ def run_tests(runtime):
timer4.interval = 500 timer4.interval = 500
print(f" Modified interval: {timer4.interval}ms") print(f" Modified interval: {timer4.interval}ms")
# Test 5: Test remaining time (periodic check - no delTimer, runs multiple times) # Test 5: Test remaining time
print("\nTest 5: Testing remaining time") print("\nTest 5: Testing remaining time")
def check_remaining(runtime): def check_remaining(runtime):
try: if timer1.active:
if timer1.active: print(f" Timer1 remaining: {timer1.remaining}ms")
print(f" Timer1 remaining: {timer1.remaining}ms") if timer2.active or timer2.paused:
if timer2.active or timer2.paused: print(f" Timer2 remaining: {timer2.remaining}ms (paused: {timer2.paused})")
print(f" Timer2 remaining: {timer2.remaining}ms (paused: {timer2.paused})")
except RuntimeError:
pass # Timer may have been cancelled
mcrfpy.setTimer("check_remaining", check_remaining, 150) mcrfpy.setTimer("check_remaining", check_remaining, 150)

View file

@ -2,8 +2,7 @@
"""Test viewport scaling modes""" """Test viewport scaling modes"""
import mcrfpy import mcrfpy
from mcrfpy import Window, Frame, Caption, Color from mcrfpy import Window, Frame, Caption, Color, Vector
from mcrfpy import automation
import sys import sys
def test_viewport_modes(runtime): def test_viewport_modes(runtime):
@ -20,56 +19,219 @@ def test_viewport_modes(runtime):
print(f"Initial scaling mode: {window.scaling_mode}") print(f"Initial scaling mode: {window.scaling_mode}")
print(f"Window resolution: {window.resolution}") print(f"Window resolution: {window.resolution}")
# Get scene # Create test scene with visual elements
scene = mcrfpy.sceneUI("test") scene = mcrfpy.sceneUI("test")
# Create a simple frame to show boundaries # Create a frame that fills the game resolution to show boundaries
game_res = window.game_resolution game_res = window.game_resolution
boundary = Frame(x=0, y=0, w=game_res[0], h=game_res[1], boundary = Frame(pos=(0, 0), size=(game_res[0], game_res[1]),
fill_color=Color(50, 50, 100), fill_color=Color(50, 50, 100),
outline_color=Color(255, 255, 255), outline_color=Color(255, 255, 255),
outline=2) outline=2)
boundary.name = "boundary"
scene.append(boundary) scene.append(boundary)
# Add corner markers
corner_size = 50
corners = [
(0, 0, "TL"), # Top-left
(game_res[0] - corner_size, 0, "TR"), # Top-right
(0, game_res[1] - corner_size, "BL"), # Bottom-left
(game_res[0] - corner_size, game_res[1] - corner_size, "BR") # Bottom-right
]
for x, y, label in corners:
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(text=label, pos=(x + 5, y + 5))
text.font_size = 20
text.fill_color = Color(255, 255, 255)
scene.append(text)
# Add center crosshair
center_x = game_res[0] // 2
center_y = game_res[1] // 2
h_line = Frame(pos=(center_x - 50, center_y - 1), size=(100, 2),
fill_color=Color(255, 255, 0))
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 # Add mode indicator
mode_text = Caption(text=f"Mode: {window.scaling_mode}", x=10, y=10) mode_text = Caption(text=f"Mode: {window.scaling_mode}", pos=(10, 10))
mode_text.font_size = 24 mode_text.font_size = 24
mode_text.fill_color = Color(255, 255, 255) mode_text.fill_color = Color(255, 255, 255)
mode_text.name = "mode_text"
scene.append(mode_text) scene.append(mode_text)
# Add instructions
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",
pos=(10, 40))
instructions.font_size = 14
instructions.fill_color = Color(200, 200, 200)
scene.append(instructions)
# Test changing modes # Test changing modes
print("\nTesting scaling modes:") def test_mode_changes(runtime):
mcrfpy.delTimer("test_modes")
from mcrfpy import automation
# Test center mode print("\nTesting scaling modes:")
window.scaling_mode = "center"
print(f"Set to center mode: {window.scaling_mode}")
mode_text.text = f"Mode: center"
automation.screenshot("viewport_center_mode.png")
# Test stretch mode # Test center mode
window.scaling_mode = "stretch" window.scaling_mode = "center"
print(f"Set to stretch mode: {window.scaling_mode}") print(f"Set to center mode: {window.scaling_mode}")
mode_text.text = f"Mode: stretch" mode_text.text = f"Mode: center (1:1 pixels)"
automation.screenshot("viewport_stretch_mode.png") automation.screenshot("viewport_center_mode.png")
# Test fit mode # Schedule next mode test
window.scaling_mode = "fit" mcrfpy.setTimer("test_stretch", test_stretch_mode, 1000)
print(f"Set to fit mode: {window.scaling_mode}")
mode_text.text = f"Mode: fit"
automation.screenshot("viewport_fit_mode.png")
# Note: Cannot change window resolution in headless mode def test_stretch_mode(runtime):
# Just verify the scaling mode properties work mcrfpy.delTimer("test_stretch")
print("\nScaling mode property tests passed!") from mcrfpy import automation
print("\nTest completed!")
sys.exit(0) window.scaling_mode = "stretch"
print(f"Set to stretch mode: {window.scaling_mode}")
mode_text.text = f"Mode: stretch (fill window)"
automation.screenshot("viewport_stretch_mode.png")
# Schedule next mode test
mcrfpy.setTimer("test_fit", test_fit_mode, 1000)
def test_fit_mode(runtime):
mcrfpy.delTimer("test_fit")
from mcrfpy import automation
window.scaling_mode = "fit"
print(f"Set to fit mode: {window.scaling_mode}")
mode_text.text = f"Mode: fit (aspect ratio maintained)"
automation.screenshot("viewport_fit_mode.png")
# Test different window sizes
mcrfpy.setTimer("test_resize", test_window_resize, 1000)
def test_window_resize(runtime):
mcrfpy.delTimer("test_resize")
from mcrfpy import automation
print("\nTesting window resize with fit mode:")
# Make window wider
window.resolution = (1280, 720)
print(f"Window resized to: {window.resolution}")
automation.screenshot("viewport_fit_wide.png")
# Make window taller
mcrfpy.setTimer("test_tall", test_tall_window, 1000)
def test_tall_window(runtime):
mcrfpy.delTimer("test_tall")
from mcrfpy import automation
window.resolution = (800, 1000)
print(f"Window resized to: {window.resolution}")
automation.screenshot("viewport_fit_tall.png")
# Test game resolution change
mcrfpy.setTimer("test_game_res", test_game_resolution, 1000)
def test_game_resolution(runtime):
mcrfpy.delTimer("test_game_res")
print("\nTesting game resolution change:")
window.game_resolution = (800, 600)
print(f"Game resolution changed to: {window.game_resolution}")
# Note: UI elements won't automatically reposition, but viewport will adjust
print("\nTest completed!")
print("Screenshots saved:")
print(" - viewport_center_mode.png")
print(" - viewport_stretch_mode.png")
print(" - viewport_fit_mode.png")
print(" - viewport_fit_wide.png")
print(" - viewport_fit_tall.png")
# Restore original settings
window.resolution = (1024, 768)
window.game_resolution = (1024, 768)
window.scaling_mode = "fit"
sys.exit(0)
# Start test sequence
mcrfpy.setTimer("test_modes", test_mode_changes, 500)
# Set up keyboard handler for manual testing
def handle_keypress(key, state):
if state != "start":
return
window = Window.get()
scene = mcrfpy.sceneUI("test")
mode_text = None
for elem in scene:
if hasattr(elem, 'name') and elem.name == "mode_text":
mode_text = elem
break
if key == "1":
window.scaling_mode = "center"
if mode_text:
mode_text.text = f"Mode: center (1:1 pixels)"
print(f"Switched to center mode")
elif key == "2":
window.scaling_mode = "stretch"
if mode_text:
mode_text.text = f"Mode: stretch (fill window)"
print(f"Switched to stretch mode")
elif key == "3":
window.scaling_mode = "fit"
if mode_text:
mode_text.text = f"Mode: fit (aspect ratio maintained)"
print(f"Switched to fit mode")
elif key == "r":
# Cycle through some resolutions
current = window.resolution
if current == (1024, 768):
window.resolution = (1280, 720)
elif current == (1280, 720):
window.resolution = (800, 600)
else:
window.resolution = (1024, 768)
print(f"Window resolution: {window.resolution}")
elif key == "g":
# Cycle game resolutions
current = window.game_resolution
if current == (1024, 768):
window.game_resolution = (800, 600)
elif current == (800, 600):
window.game_resolution = (640, 480)
else:
window.game_resolution = (1024, 768)
print(f"Game resolution: {window.game_resolution}")
elif key == "escape":
sys.exit(0)
# Main execution # Main execution
print("Creating viewport test scene...") print("Creating viewport test scene...")
mcrfpy.createScene("test") mcrfpy.createScene("test")
mcrfpy.setScene("test") mcrfpy.setScene("test")
mcrfpy.keypressScene(handle_keypress)
# Schedule the test # Schedule the test
mcrfpy.setTimer("test_viewport", test_viewport_modes, 100) mcrfpy.setTimer("test_viewport", test_viewport_modes, 100)
print("Viewport test running...") print("Viewport test running...")
print("Use number keys to switch modes, R to resize window, G to change game resolution")