SDL_image: cmake: add exports file for Apple

From 24f3f85e1fff75b257086f642deffc9c205adb1d Mon Sep 17 00:00:00 2001
From: Anonymous Maarten <[EMAIL REDACTED]>
Date: Fri, 14 Nov 2025 22:32:30 +0100
Subject: [PATCH] cmake: add exports file for Apple

---
 .github/workflows/main.yml |   8 +-
 CMakeLists.txt             |   5 +-
 src/SDL_image.exports      | 101 +++++++
 src/SDL_image.sym          |   3 +
 src/genexports.py          | 574 +++++++++++++++++++++++++++++++++++++
 5 files changed, 689 insertions(+), 2 deletions(-)
 create mode 100644 src/SDL_image.exports
 create mode 100755 src/genexports.py

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 69f994f05..823f50e6c 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -19,7 +19,7 @@ jobs:
             cmake: '-DSDLIMAGE_BACKEND_STB=OFF -DPerl_ROOT=C:/Strawberry/perl/bin/ -GNinja -DCMAKE_POLICY_DEFAULT_CMP0141=NEW -DCMAKE_MSVC_DEBUG_INFORMATION_FORMAT=ProgramDatabase -DCMAKE_EXE_LINKER_FLAGS=-DEBUG -DCMAKE_SHARED_LINKER_FLAGS=-DEBUG' }
         - { name: Windows (mingw64), os: windows-latest, shell: 'msys2 {0}', vendored: false, msystem: mingw64, msys-env: mingw-w64-x86_64, shared: 1, static: 0, artifact: 'SDL3_image-mingw64',
             cmake: '-DSDLIMAGE_BACKEND_STB=OFF -DSDLIMAGE_BACKEND_WIC=OFF  -DSDLIMAGE_AVIF=ON -G "Ninja Multi-Config"' }
-        - { name: Linux,             os: ubuntu-latest,  shell: sh,  vendored: false, cmake: '-GNinja', shared: 1, static: 0, noavif: true, nojxl: true, artifact: 'SDL3_image-linux-x64' }
+        - { name: Linux,             os: ubuntu-latest,  shell: sh,  vendored: false, cmake: '-GNinja', shared: 1, static: 0, noavif: true, nojxl: true, test-exports: true, artifact: 'SDL3_image-linux-x64' }
         - { name: 'Linux (static)',  os: ubuntu-latest,  shell: sh,  vendored: true, cmake: '-DBUILD_SHARED_LIBS=OFF -GNinja', shared: 0, static: 1, artifact: 'SDL3_image-static-linux-x64' }
         - { name: Macos,             os: macos-latest,   shell: sh,  vendored: false, cmake: '-DSDLIMAGE_DEPS_SHARED=OFF -GNinja', shared: 1, static: 0, artifact: 'SDL3_image-macos' }
 
@@ -177,6 +177,12 @@ jobs:
           build/CMakeCache.txt
           build/CMakeFiles/*yaml
 
+    - name: Verify exports files
+      if: ${{ matrix.platform.test-exports }}
+      run: |
+        set -e
+        python src/genexports.py
+        git diff --exit-code -- src/SDL_image.sym src/SDL_image.exports
     - name: Verify CMake configuration files
       run: |
         cmake -S cmake/test -B cmake_config_build \
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a2f52d6fa..7b0c4a1e4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -336,12 +336,15 @@ if(NOT ANDROID)
         VERSION "${SO_VERSION}"
     )
     if(APPLE)
-        cmake_minimum_required(VERSION 3.17...3.28)
         set_target_properties(${sdl3_image_target_name} PROPERTIES
             MACHO_COMPATIBILITY_VERSION "${DYLIB_COMPAT_VERSION}"
             MACHO_CURRENT_VERSION "${DYLIB_CURRENT_VERSION}"
         )
         sdl_no_deprecated_errors(${sdl3_image_target_name})
+        set_property(TARGET ${sdl3_image_target_name} APPEND PROPERTY LINK_DEPENDS
+            "${PROJECT_SOURCE_DIR}/src/SDL_image.exports")
+        target_link_options(${sdl3_image_target_name} PRIVATE
+            "SHELL:-Wl,-exported_symbols_list,${PROJECT_SOURCE_DIR}/src/SDL_image.exports")
     endif()
 endif()
 if(SDLIMAGE_BUILD_SHARED_LIBS)
diff --git a/src/SDL_image.exports b/src/SDL_image.exports
new file mode 100644
index 000000000..da3336a4b
--- /dev/null
+++ b/src/SDL_image.exports
@@ -0,0 +1,101 @@
+# SDL3_image.dylib exports
+_IMG_FreeAnimation
+_IMG_Version
+_IMG_Load
+_IMG_LoadAVIF_IO
+_IMG_LoadAnimation
+_IMG_LoadAnimationTyped_IO
+_IMG_LoadAnimation_IO
+_IMG_LoadBMP_IO
+_IMG_LoadCUR_IO
+_IMG_LoadGIFAnimation_IO
+_IMG_LoadGIF_IO
+_IMG_LoadICO_IO
+_IMG_LoadJPG_IO
+_IMG_LoadJXL_IO
+_IMG_LoadLBM_IO
+_IMG_LoadPCX_IO
+_IMG_LoadPNG_IO
+_IMG_LoadPNM_IO
+_IMG_LoadQOI_IO
+_IMG_LoadSVG_IO
+_IMG_LoadSizedSVG_IO
+_IMG_LoadTGA_IO
+_IMG_LoadTIF_IO
+_IMG_LoadTexture
+_IMG_LoadTextureTyped_IO
+_IMG_LoadTexture_IO
+_IMG_LoadTyped_IO
+_IMG_LoadWEBPAnimation_IO
+_IMG_LoadWEBP_IO
+_IMG_LoadXCF_IO
+_IMG_LoadXPM_IO
+_IMG_LoadXV_IO
+_IMG_Load_IO
+_IMG_ReadXPMFromArray
+_IMG_ReadXPMFromArrayToRGB888
+_IMG_SaveJPG
+_IMG_SaveJPG_IO
+_IMG_SavePNG
+_IMG_SavePNG_IO
+_IMG_SaveAVIF
+_IMG_SaveAVIF_IO
+_IMG_isANI
+_IMG_isAVIF
+_IMG_isBMP
+_IMG_isCUR
+_IMG_isGIF
+_IMG_isICO
+_IMG_isJPG
+_IMG_isJXL
+_IMG_isLBM
+_IMG_isPCX
+_IMG_isPNG
+_IMG_isPNM
+_IMG_isQOI
+_IMG_isSVG
+_IMG_isTIF
+_IMG_isWEBP
+_IMG_isXCF
+_IMG_isXPM
+_IMG_isXV
+_IMG_SaveTGA
+_IMG_SaveTGA_IO
+_IMG_SaveGIF
+_IMG_SaveGIF_IO
+_IMG_SaveWEBP
+_IMG_SaveWEBP_IO
+_IMG_SaveBMP
+_IMG_SaveBMP_IO
+_IMG_Save
+_IMG_SaveTyped_IO
+_IMG_CreateAnimationEncoder
+_IMG_CreateAnimationEncoder_IO
+_IMG_CreateAnimationEncoderWithProperties
+_IMG_AddAnimationEncoderFrame
+_IMG_CloseAnimationEncoder
+_IMG_LoadANIAnimation_IO
+_IMG_LoadAPNGAnimation_IO
+_IMG_LoadAVIFAnimation_IO
+_IMG_CreateAnimationDecoder
+_IMG_CreateAnimationDecoder_IO
+_IMG_CreateAnimationDecoderWithProperties
+_IMG_GetAnimationDecoderFrame
+_IMG_ResetAnimationDecoder
+_IMG_CloseAnimationDecoder
+_IMG_GetAnimationDecoderProperties
+_IMG_GetAnimationDecoderStatus
+_IMG_GetClipboardImage
+_IMG_CreateAnimatedCursor
+_IMG_SaveCUR
+_IMG_SaveCUR_IO
+_IMG_SaveICO
+_IMG_SaveICO_IO
+_IMG_SaveAnimation
+_IMG_SaveAnimationTyped_IO
+_IMG_SaveANIAnimation_IO
+_IMG_SaveAPNGAnimation_IO
+_IMG_SaveAVIFAnimation_IO
+_IMG_SaveGIFAnimation_IO
+_IMG_SaveWEBPAnimation_IO
+# extra symbols go here (don't modify this line)
diff --git a/src/SDL_image.sym b/src/SDL_image.sym
index 9a7ab6283..19c75eb22 100644
--- a/src/SDL_image.sym
+++ b/src/SDL_image.sym
@@ -41,6 +41,7 @@ SDL3_image_0.0.0 {
     IMG_SavePNG_IO;
     IMG_SaveAVIF;
     IMG_SaveAVIF_IO;
+    IMG_isANI;
     IMG_isAVIF;
     IMG_isBMP;
     IMG_isCUR;
@@ -74,6 +75,7 @@ SDL3_image_0.0.0 {
     IMG_CreateAnimationEncoderWithProperties;
     IMG_AddAnimationEncoderFrame;
     IMG_CloseAnimationEncoder;
+    IMG_LoadANIAnimation_IO;
     IMG_LoadAPNGAnimation_IO;
     IMG_LoadAVIFAnimation_IO;
     IMG_CreateAnimationDecoder;
@@ -97,5 +99,6 @@ SDL3_image_0.0.0 {
     IMG_SaveAVIFAnimation_IO;
     IMG_SaveGIFAnimation_IO;
     IMG_SaveWEBPAnimation_IO;
+    # extra symbols go here (don't modify this line)
   local: *;
 };
diff --git a/src/genexports.py b/src/genexports.py
new file mode 100755
index 000000000..6bbe7d000
--- /dev/null
+++ b/src/genexports.py
@@ -0,0 +1,574 @@
+#!/usr/bin/env python3
+
+#  Simple DirectMedia Layer
+#  Copyright (C) 1997-2025 Sam Lantinga <slouken@libsdl.org>
+#
+#  This software is provided 'as-is', without any express or implied
+#  warranty.  In no event will the authors be held liable for any damages
+#  arising from the use of this software.
+#
+#  Permission is granted to anyone to use this software for any purpose,
+#  including commercial applications, and to alter it and redistribute it
+#  freely, subject to the following restrictions:
+#
+#  1. The origin of this software must not be misrepresented; you must not
+#     claim that you wrote the original software. If you use this software
+#     in a product, an acknowledgment in the product documentation would be
+#     appreciated but is not required.
+#  2. Altered source versions must be plainly marked as such, and must not be
+#     misrepresented as being the original software.
+#  3. This notice may not be removed or altered from any source distribution.
+
+# WHAT IS THIS?
+#  When you add a public API to a SDL library, please run this script, make sure the
+#  output looks sane (git diff, it adds to existing files), and commit it.
+#  It keeps the export lists in sync with the API.
+#
+
+import argparse
+import dataclasses
+import json
+import logging
+import os
+from pathlib import Path
+import pprint
+import re
+
+RE_EXTERN_C = re.compile(r'.*extern[ "]*C[ "].*')
+RE_COMMENT_REMOVE_CONTENT = re.compile(r'\/\*.*\*/')
+RE_PARSING_FUNCTION = re.compile(r'(.*SDLCALL[^\(\)]*) ([a-zA-Z0-9_]+) *\((.*)\) *;.*')
+
+#eg:
+# void (SDLCALL *callback)(void*, int)
+# \1(\2)\3
+RE_PARSING_CALLBACK = re.compile(r'([^\(\)]*)\(([^\(\)]+)\)(.*)')
+
+
+logger = logging.getLogger(__name__)
+
+
+@dataclasses.dataclass(frozen=True)
+class SdlProjectSymbolProperties:
+    include_dir: Path
+    version_export_path: Path
+    macos_exports_path: Path
+    re_symbol: str
+
+    @classmethod
+    def from_root(cls, project_root: Path) -> "SdlProjectSymbolProperties":
+        wikiheaders_options = {}
+        with (project_root / ".wikiheaders-options").open("r", newline="\n") as f:
+            for line in f.readlines():
+                key, value = line.split("=", 1)
+                key, value = key.strip(), value.strip()
+                wikiheaders_options[key] = value
+        return cls(
+            include_dir=project_root / wikiheaders_options["incsubdir"],
+            version_export_path=project_root / "src" / (wikiheaders_options["projectfullname"] + ".sym"),
+            macos_exports_path=project_root / "src" / (wikiheaders_options["projectfullname"] + ".exports"),
+            re_symbol= wikiheaders_options["apiprefixregex"],
+        )
+
+@dataclasses.dataclass(frozen=True)
+class SdlProcedure:
+    retval: str
+    name: str
+    parameter: list[str]
+    parameter_name: list[str]
+    header: str
+    comment: str
+
+    @property
+    def variadic(self) -> bool:
+        return "..." in self.parameter
+
+
+def parse_header(header_path: Path) -> list[SdlProcedure]:
+    logger.debug("Parse header: %s", header_path)
+
+    header_procedures = []
+
+    parsing_function = False
+    current_func = ""
+    parsing_comment = False
+    current_comment = ""
+    ignore_wiki_documentation = False
+
+    with header_path.open() as f:
+        for line in f:
+
+            # Skip lines if we're in a wiki documentation block.
+            if ignore_wiki_documentation:
+                if line.startswith("#endif"):
+                    ignore_wiki_documentation = False
+                continue
+
+            # Discard wiki documentations blocks.
+            if line.startswith("#ifdef SDL_WIKI_DOCUMENTATION_SECTION"):
+                ignore_wiki_documentation = True
+                continue
+
+            # Discard pre-processor directives ^#.*
+            if line.startswith("#"):
+                continue
+
+            # Discard "extern C" line
+            match = RE_EXTERN_C.match(line)
+            if match:
+                continue
+
+            # Remove one line comment // ...
+            # eg: extern SDL_DECLSPEC SDL_hid_device * SDLCALL SDL_hid_open_path(const char *path, int bExclusive /* = false */)
+            line = RE_COMMENT_REMOVE_CONTENT.sub('', line)
+
+            # Get the comment block /* ... */ across several lines
+            match_start = "/*" in line
+            match_end = "*/" in line
+            if match_start and match_end:
+                continue
+            if match_start:
+                parsing_comment = True
+                current_comment = line
+                continue
+            if match_end:
+                parsing_comment = False
+                current_comment += line
+                continue
+            if parsing_comment:
+                current_comment += line
+                continue
+
+            # Get the function prototype across several lines
+            if parsing_function:
+                # Append to the current function
+                current_func += " "
+                current_func += line.strip()
+            else:
+                # if is contains "extern", start grabbing
+                if "extern" not in line:
+                    continue
+                # Start grabbing the new function
+                current_func = line.strip()
+                parsing_function = True
+
+            # If it contains ';', then the function is complete
+            if ";" not in current_func:
+                continue
+
+            # Got function/comment, reset vars
+            parsing_function = False
+            func = current_func
+            comment = current_comment
+            current_func = ""
+            current_comment = ""
+
+            # Discard if it doesn't contain 'SDLCALL'
+            if "SDLCALL" not in func:
+                logger.debug("  Discard, doesn't have SDLCALL: %r", func)
+                continue
+
+            # Discard if it contains 'SDLMAIN_DECLSPEC' (these are not SDL symbols).
+            if "SDLMAIN_DECLSPEC" in func:
+                logger.debug("  Discard, has SDLMAIN_DECLSPEC: %r", func)
+                continue
+
+            logger.debug("Raw data: %r", func)
+
+            # Replace unusual stuff...
+            func = func.replace(" SDL_PRINTF_VARARG_FUNC(1)", "")
+            func = func.replace(" SDL_PRINTF_VARARG_FUNC(2)", "")
+            func = func.replace(" SDL_PRINTF_VARARG_FUNC(3)", "")
+            func = func.replace(" SDL_PRINTF_VARARG_FUNC(4)", "")
+            func = func.replace(" SDL_PRINTF_VARARG_FUNCV(1)", "")
+            func = func.replace(" SDL_PRINTF_VARARG_FUNCV(2)", "")
+            func = func.replace(" SDL_PRINTF_VARARG_FUNCV(3)", "")
+            func = func.replace(" SDL_PRINTF_VARARG_FUNCV(4)", "")
+            func = func.replace(" SDL_WPRINTF_VARARG_FUNC(3)", "")
+            func = func.replace(" SDL_WPRINTF_VARARG_FUNCV(3)", "")
+            func = func.replace(" SDL_SCANF_VARARG_FUNC(2)", "")
+            func = func.replace(" SDL_SCANF_VARARG_FUNCV(2)", "")
+            func = func.replace(" SDL_ANALYZER_NORETURN", "")
+            func = func.replace(" SDL_MALLOC", "")
+            func = func.replace(" SDL_ALLOC_SIZE2(1, 2)", "")
+            func = func.replace(" SDL_ALLOC_SIZE(2)", "")
+            func = re.sub(r" SDL_ACQUIRE\(.*\)", "", func)
+            func = re.sub(r" SDL_ACQUIRE_SHARED\(.*\)", "", func)
+            func = re.sub(r" SDL_TRY_ACQUIRE\(.*\)", "", func)
+            func = re.sub(r" SDL_TRY_ACQUIRE_SHARED\(.*\)", "", func)
+            func = re.sub(r" SDL_RELEASE\(.*\)", "", func)
+            func = re.sub(r" SDL_RELEASE_SHARED\(.*\)", "", func)
+            func = re.sub(r" SDL_RELEASE_GENERIC\(.*\)", "", func)
+            func = re.sub(r"([ (),])(SDL_IN_BYTECAP\([^)]*\))", r"\1", func)
+            func = re.sub(r"([ (),])(SDL_OUT_BYTECAP\([^)]*\))", r"\1", func)
+            func = re.sub(r"([ (),])(SDL_INOUT_Z_CAP\([^)]*\))", r"\1", func)
+            func = re.sub(r"([ (),])(SDL_OUT_Z_CAP\([^)]*\))", r"\1", func)
+
+            # Should be a valid function here
+            match = RE_PARSING_FUNCTION.match(func)
+            if not match:
+                logger.error("Cannot parse: %s", func)
+                raise ValueError(func)
+
+            func_ret = match.group(1)
+            func_name = match.group(2)
+            func_params = match.group(3)
+
+            #
+            # Parse return value
+            #
+            func_ret = func_ret.replace('extern', ' ')
+            func_ret = func_ret.replace('SDLCALL', ' ')
+            func_ret = func_ret.replace('SDL_DECLSPEC', ' ')
+            func_ret, _ = re.subn('([ ]{2,})', ' ', func_ret)
+            # Remove trailing spaces in front of '*'
+            func_ret = func_ret.replace(' *', '*')
+            func_ret = func_ret.strip()
+
+            #
+            # Parse parameters
+            #
+            func_params = func_params.strip()
+            if func_params == "":
+                func_params = "void"
+
+            # Identify each function parameters with type and name
+            # (eventually there are callbacks of several parameters)
+            tmp = func_params.split(',')
+            tmp2 = []
+            param = ""
+            for t in tmp:
+                if param == "":
+                    param = t
+                else:
+                    param = param + "," + t
+                # Identify a callback or parameter when there is same count of '(' and ')'
+                if param.count('(') == param.count(')'):
+                    tmp2.append(param.strip())
+                    param = ""
+
+            # Process each parameters, separation name and type
+            func_param_type = []
+            func_param_name = []
+            for t in tmp2:
+                if t == "void":
+                    func_param_type.append(t)
+                    func_param_name.append("")
+                    continue
+
+                if t == "...":
+                    func_param_type.append(t)
+                    func_param_name.append("")
+                    continue
+
+                param_name = ""
+
+                # parameter is a callback
+                if '(' in t:
+                    match = RE_PARSING_CALLBACK.match(t)
+                    if not match:
+                        logger.error("cannot parse callback: %s", t)
+                        raise ValueError(t)
+                    a = match.group(1).strip()
+                    b = match.group(2).strip()
+                    c = match.group(3).strip()
+
+                    try:
+                        (param_type, param_name) = b.rsplit('*', 1)
+                    except:
+                        param_type = t
+                        param_name = "param_name_not_specified"
+
+                    # bug rsplit ??
+                    if param_name == "":
+                        param_name = "param_name_not_specified"
+
+                    # reconstruct a callback name for future parsing
+                    func_param_type.append(a + " (" + param_type.strip() + " *REWRITE_NAME)" + c)
+                    func_param_name.append(param_name.strip())
+
+                    continue
+
+                # array like "char *buf[]"
+                has_array = False
+                if t.endswith("[]"):
+                    t = t.replace("[]", "")
+                    has_array = True
+
+                # pointer
+                if '*' in t:
+                    try:
+                        (param_type, param_name) = t.rsplit('*', 1)
+                    except:
+                        param_type = t
+                        param_name = "param_name_not_specified"
+
+                    # bug rsplit ??
+                    if param_name == "":
+                        param_name = "param_name_not_specified"
+
+                    val = param_type.strip() + "*REWRITE_NAME"
+
+                    # Remove trailing spaces in front of '*'
+                    tmp = ""
+                    while val != tmp:
+                        tmp = val
+                        val = val.replace('  ', ' ')
+                    val = val.replace(' *', '*')
+                    # first occurrence
+                    val = val.replace('*', ' *', 1)
+                    val = val.strip()
+
+                else: # non pointer
+                    # cut-off last word on
+                    try:
+                        (param_type, param_name) = t.rsplit(' ', 1)
+                    except:
+                        param_type = t
+                        param_name = "param_name_not_specified"
+
+                    val = param_type.strip() + " REWRITE_NAME"
+
+                # set back array
+                if has_array:
+                    val += "[]"
+
+                func_param_type.append(val)
+                func_param_name.append(param_name.strip())
+
+            new_proc = SdlProcedure(
+                retval=func_ret,                # Return value type
+                name=func_name,                 # Function name
+                comment=comment,                # Function comment
+                header=header_path.name,        # Header file
+                parameter=func_param_type,      # List of parameters (type + anonymized param name 'REWRITE_NAME')
+                parameter_name=func_param_name, # Real parameter name, or 'param_name_not_specified'
+            )
+
+            header_procedures.append(new_proc)
+
+            if logger.getEffectiveLevel() <= logging.DEBUG:
+                logger.debug("%s", pprint.pformat(new_proc))
+
+    return header_procedures
+
+
+# Dump API into a json file
+def full_API_json(path: Path, procedures: list[SdlProcedure]):
+    with path.open('w', newline='') as f:
+        json.dump([dataclasses.asdict(proc) for proc in procedures], f, indent=4, sort_keys=True)
+        logger.info("dump API to '%s'", path)
+
+
+class CallOnce:
+    def __init__(self, cb):
+        self._cb = cb
+        self._called = False
+    def __call__(self, *args, **kwargs):
+        if self._called:
+            return
+        self._called = True
+        self._cb(*args, **kwargs)
+
+
+# Check public function comments are correct
+def print_check_comment_header():
+    logger.warning("")
+    logger.warning("Please fix following warning(s):")
+    logger.warning("--------------------------------")
+
+
+def check_documentations(procedures: list[SdlProcedure]) -> None:
+
+    check_comment_header = CallOnce(print_check_comment_header)
+
+    warning_header_printed = False
+
+    # Check \param
+    for proc in procedures:
+        expected = len(proc.parameter)
+        if expected == 1:
+            if proc.parameter[0] == 'void':
+                expected = 0
+        count = proc.comment.count("\\param")
+        if count != expected:
+            # skip SDL_stdinc.h
+            if proc.header != 'SDL_stdinc.h':
+                # Warning mismatch \param and function prototype
+                check_comment_header()
+                logger.warning("  In file %s: function %s() has %d '\\param' but expected %d", proc.header, proc.name, count, expected)
+
+        # Warning check \param uses the correct parameter name
+        # skip SDL_stdinc.h
+        if proc.header != 'SDL_stdinc.h':
+            for n in proc.parameter_name:
+                if n != "" and "\\param " + n not in proc.comment and "\\param[out] " + n not in proc.comment:
+                    check_comment_header()
+                    logger.warning("  In file %s: function %s() missing '\\param %s'", proc.header, proc.name, n)
+
+    # Check \returns
+    for proc in procedures:
+        expected = 1
+        if proc.retval == 'void':
+            expected = 0
+
+        count = proc.comment.count("\\returns")
+        if count != expected:
+            # skip SDL_stdinc.h
+            if proc.header != 'SDL_stdinc.h':
+                # Warning mismatch \param and function prototype
+                check_comment_header()
+                logger.warning("  In file %s: function %s() has %d '\\returns' but expected %d" % (proc.header, proc.name, count, expected))
+
+    # Check \since
+    for proc in procedures:
+        expected = 1
+        count = proc.comment.count("\\since")
+        if count != expected:
+            # skip SDL_stdinc.h
+            if proc.header != 'SDL_stdinc.h':
+                # Warning mismatch \param and function prototype
+                check_comment_header()
+                logger.warning("  In file %s: function %s() has %d '\\since' but expected %d" % (proc.header, proc.name, count, expected))
+
+
+# Parse 'sdl_dynapi_procs_h' file to find existing functions
+def find_existing_proc_names(project_properties: SdlProjectSymbolProperties) -> set[str]:
+    versioned_symbols = set()
+    re_version_export_symbol = re.compile(r'\s*(' + project_properties.re_symbol + r"[a-zA-Z0-9_]+);\s*")
+    with project_properties.version_export_path.open() as f:
+        for line in f:
+            match = re_version_export_symbol.match(line)
+            if not match:
+                continue
+            existing_func = match.group(1)
+            versioned_symbols.add(existing_func)
+    logger.debug("symbols from version script: %r", versioned_symbols)
+
+    macos_symbols = set()
+    re_macos_export_symbol = re.compile(r'\s*_(' + project_properties.re_symbol + r"[a-zA-Z0-9_]+)\s*")
+    with project_properties.macos_exports_path.open() as f:
+        for line in f:
+            match = re_macos_export_symbol.match(line)
+            if not match:
+                continue
+            existing_func = match.group(1)
+            macos_symbols.add(existing_func)
+    logger.debug("symbols from macos exports file: %r", macos_symbols)
+
+    non_matchin_symbols = versioned_symbols.difference(macos_symbols)
+    if non_matchin_symbols:
+        logger.error("Following symbols do not match: %r", non_matching_symbols)
+        raise RuntimeError("Non-matching symbols", non_matching_symbols)
+    return versioned_symbols
+
+# Get list of SDL headers
+def get_header_list(project_properties: SdlProjectSymbolProperties) -> list[Path]:
+    ret = []
+
+    for f in project_properties.include_dir.iterdir():
+        # Only *.h files
+        if f.is_file() and f.suffix == ".h":
+            ret.append(f)
+        else:
+            logger.debug("Skip %s", f)
+
+    # Order headers for reproducible behavior
+    ret.sort()
+
+    return ret
+
+# Write the new API in files: _procs.h _overrides.h and .sym
+def add_dyn_api(proc: SdlProcedure, project_properties:SdlProjectSymbolProperties) -> None:
+    decl_args: list[str] = []
+    call_args = []
+    for i, argtype in enumerate(proc.parameter):
+        # Special case, void has no parameter name
+        if argtype == "void":
+            assert len(decl_args) == 0
+            assert len(proc.parameter) == 1
+            decl_args.append("void")
+            continue
+
+        # Var name: a, b, c, ...
+        varname = chr(ord('a') + i)
+
+        decl_args.append(argtype.replace("REWRITE_NAME", varname))
+        if argtype != "...":
+            call_args.append(varname)
+
+    macro_args = (
+        proc.retval,
+        proc.name,
+        "({})".format(",".join(decl_args)),
+        "({})".format(",".join(call_args)),
+        "" if proc.retval == "void" else "return",
+    )
+
+    # File: SDL_{image,mixer,net,ttf}.sym
+    #
+    # Add before "extra symbols go here" line
+    with project_properties.version_export_path.open(newline="\n") as f:
+        new_input = []
+        for line in f:
+            if "extra symbols go here" in line:
+                new_input.append(f"    {proc.name};\n")
+            new_input.append(line)
+
+    with project_properties.version_export_path.open('w', newline='') as f:
+        for line in new_input:
+            f.write(line)
+
+    # File: SDL_{image,mixer,net,ttf}.exports
+    #
+    # Add before "extra symbols go here" line
+    with project_properties.macos_exports_path.open(newline="\n") as f:
+        new_input = []
+        for line in f:
+            if "extra symbols go here" in line:
+                new_input.append(f"_{proc.name}\n")
+            new_input.append(line)
+
+    with project_properties.macos_exports_path.open("w", newline="\n") as f:
+        for line in new_input:
+            f.write(line)
+
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.set_defaults(loglevel=logging.INFO)
+    parser.add_argument('--dump', nargs='?', default=None, const="sdl.json", metavar="JSON", help='output all API into a .json file')
+    parser.add_argument('--debug', action='store_const', const=logging.DEBUG, dest="loglevel", help='add debug traces')
+    args = parser.parse_args()
+
+    logging.basicConfig(level=args.loglevel, format='[%(levelname)s] %(message)s')
+
+    root = Path(__file__).resolve().parents[1]
+
+    project_properties = SdlProjectSymbolProperties.from_root(root)
+    logger.debug("project_properties=%r", project_properties)
+
+    # Get list of SDL headers
+    sdl_list_includes = get_header_list(project_properties)
+    procedures = []
+    for filename in sdl_list_includes:
+        header_procedures = parse_header(filename)
+        procedures.extend(header_procedures)
+
+    # Parse 'SDL_{image,mixer,ttf}.sym' file to find existing functions
+    existing_proc_names = find_existing_proc_names(project_properties)
+    for procedure in procedures:
+        if procedure.name not in existing_proc_names:
+            logger.info("NEW %s", procedure.name)
+            add_dyn_api(procedure, project_properties)
+
+    if args.dump:
+        # Dump API into a json file
+        full_API_json(path=Path(args.dump), procedures=procedures)
+
+    # Check comment formatting
+    check_documentations(procedures)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())